feat: 添加消息推送能力,添加新闻催化分析页的合规提示

This commit is contained in:
zdl
2025-10-21 10:59:52 +08:00
parent 6c96299b8f
commit 5a3a3ad42b
15 changed files with 1800 additions and 125 deletions

View File

@@ -1,7 +1,7 @@
{
"permissions": {
"allow": [
"Bash(find src -name \"*.js\" -type f -exec grep -l \"process.env.REACT_APP_API_URL || [''''\"\"]http\" {})"
"Read(//Users/qiye/**)"
],
"deny": [],
"ask": []

455
NOTIFICATION_SYSTEM.md Normal file
View File

@@ -0,0 +1,455 @@
# 实时消息推送系统使用指南
## 🆕 最新更新 (v1.1.0)
-**新消息置顶展示**:采用行业标准,新消息显示在最上方
-**智能队列管理**:最多同时显示 5 条消息,超出自动移除最旧的
-**视觉层次优化**:最新消息更强的阴影和边框高亮效果
-**测试工具增强**:实时显示队列状态,新增"测试最大限制"功能
## 📋 系统概述
本系统实现了完整的实时消息推送功能,支持 Mock 模式(开发)和真实 Socket.IO 模式(生产)。消息以右下角层叠弹窗的形式显示,**新消息在最上方**,符合主流桌面应用的交互习惯。
---
## 🎯 功能特性
### ✅ 已实现功能
1. **实时推送**
- WebSocket (Socket.IO) 连接
- Mock 模式自动定期推送(开发环境)
- 支持多种消息类型(成功、错误、警告、信息)
2. **UI 展示**
- 右下角固定定位
- **新消息在最上方**(符合行业标准)
- 层叠显示最多 5 条消息
- 智能队列管理:超出自动移除最旧消息
- 视觉层次:最新消息更突出(更强阴影、边框高亮)
- 自动关闭(可配置时长)
- 手动关闭按钮
- 流畅的进入/退出动画(从右侧滑入)
3. **音效提示**
- 新消息音效播放
- 可开关音效
4. **开发工具**
- 右上角测试工具面板(仅开发环境)
- 手动触发测试通知4种类型
- 层叠效果测试4条消息
- 最大限制测试6条→5条
- **实时队列状态显示**(当前消息数 / 5
- 连接状态显示
- 音效开关
- 测试计数统计
---
## 📁 文件结构
```
src/
├── services/
│ ├── socket/
│ │ └── index.js # Socket 服务统一导出
│ ├── socketService.js # 真实 Socket.IO 服务
│ └── mockSocketService.js # Mock Socket 服务
├── contexts/
│ └── NotificationContext.js # 通知上下文管理
├── components/
│ ├── NotificationContainer/
│ │ └── index.js # 通知容器组件
│ └── NotificationTestTool/
│ └── index.js # 测试工具组件
└── assets/
└── sounds/
└── notification.wav # 通知音效
```
---
## 📐 展示逻辑说明
### 消息排列方式
本系统采用**新消息在最上方**的展示模式这是桌面应用的行业标准Windows、macOS、Slack、Discord等
```
用户视角(右下角):
第1条消息到达
┌────────────────────┐
│ 🔔 买入成功 🆕 │ ← 从右侧滑入
└────────────────────┘
第2条消息到达
┌────────────────────┐
│ 🔔 价格预警 🆕 │ ← 新消息(最上方)
├────────────────────┤
│ 🔔 买入成功 │ ← 旧消息向下平移
└────────────────────┘
第6条消息到达超过5条限制
┌────────────────────┐
│ 🔔 第6条 🆕 │ ← 最新
├────────────────────┤
│ 🔔 第5条 │
├────────────────────┤
│ 🔔 第4条 │
├────────────────────┤
│ 🔔 第3条 │
├────────────────────┤
│ 🔔 第2条 │ ← 最旧(仍显示)
└────────────────────┘
↓ 自动移除
│ 第1条已移除
```
### 关键特性
- **最新消息固定位置**:始终在右下角的顶部,便于快速注意
- **自动队列管理**:最多保留 5 条,超出自动移除最旧的
- **视觉区分**最新消息有更强的阴影2xl vs lg和边框高亮
- **z-index 层级**最新消息层级最高9999依次递减
- **间距优化**:消息之间 12px 间距spacing={3}
---
## 🚀 快速开始
### 1. 启用 Mock 模式
`.env` 文件中添加:
```bash
REACT_APP_ENABLE_MOCK=true
# 或
REACT_APP_USE_MOCK_SOCKET=true
```
### 2. 启动项目
```bash
npm start
```
### 3. 测试通知
打开浏览器,右上角会显示 **"通知测试工具"**,点击展开后可以:
- **单条测试**:测试不同类型的通知(成功、错误、警告、信息)
- **层叠测试**:一次发送 4 条消息,测试层叠效果
- **最大限制测试**:发送 6 条消息,验证只保留最新 5 条
- **队列状态**实时显示当前队列中的消息数量X / 5
- **音效控制**:切换音效开关
- **清空功能**:一键清空所有通知
- **连接状态**:查看 Socket 连接状态和服务类型MOCK/REAL
### 4. 自动推送
在 Mock 模式下,系统会自动每 20 秒推送 1-2 条随机消息,用于测试层叠效果。
---
## 💻 代码使用
### 在组件中使用通知
```javascript
import { useNotification } from 'contexts/NotificationContext';
function MyComponent() {
const { addNotification, isConnected } = useNotification();
const handleTradeSuccess = () => {
addNotification({
type: 'trade_alert',
severity: 'success',
title: '买入成功',
message: '您的订单已成功执行:买入 贵州茅台(600519) 100股',
autoClose: 8000, // 8秒后自动关闭
});
};
return (
<div>
<p>连接状态: {isConnected ? '已连接' : '未连接'}</p>
<button onClick={handleTradeSuccess}>模拟交易成功</button>
</div>
);
}
```
### 消息格式
```javascript
{
type: 'trade_alert', // 消息类型
severity: 'info', // 'success' | 'error' | 'warning' | 'info'
title: '通知标题', // 主标题
message: '详细消息内容', // 详细内容
timestamp: Date.now(), // 时间戳(自动生成)
autoClose: 8000, // 自动关闭时长毫秒0 或 false 表示不自动关闭
id: 'unique_id' // 唯一ID自动生成
}
```
---
## 🔧 配置说明
### Mock 服务配置
`src/services/mockSocketService.js` 中可以配置:
```javascript
// 修改模拟数据
const mockTradeAlerts = [
{
severity: 'success',
title: '自定义标题',
message: '自定义消息',
autoClose: 8000,
},
// 添加更多...
];
// 调整推送频率
socket.startMockPush(20000, 2); // 每20秒推送1-2条
```
### NotificationContext 配置
`src/contexts/NotificationContext.js` 中:
```javascript
// 修改最大消息数量(默认 5 条)
const maxNotifications = 5; // 修改为其他数值
// 修改默认音效状态
const [soundEnabled, setSoundEnabled] = useState(true); // false 关闭音效
// 修改音效音量
audioRef.current.volume = 0.5; // 0.0 - 1.0
```
### NotificationContainer 配置
`src/components/NotificationContainer/index.js` 中:
```javascript
// 调整消息间距
<VStack spacing={3}> // 3 = 12px, 4 = 16px, 2 = 8px
// 调整位置
<Box
position="fixed"
bottom={6} // 修改为 top={6} 可以显示在右上角
right={6} // 修改为 left={6} 可以显示在左侧
>
```
---
## 🌐 后端集成(生产环境)
### 1. Flask 后端配置
`REACT_APP_ENABLE_MOCK=false` 时,系统会连接真实的 Socket.IO 服务器。
`app.py` 中初始化 Flask-SocketIO
```python
from flask_socketio import SocketIO, emit
# 初始化 SocketIO
socketio = SocketIO(app, cors_allowed_origins=[
"http://localhost:3000",
"https://valuefrontier.cn"
])
# 连接事件
@socketio.on('connect')
def handle_connect():
print(f'Client connected: {request.sid}')
# 推送交易通知
def send_trade_notification(user_id, data):
"""
推送交易通知到指定用户
"""
emit('trade_notification', data, room=user_id)
# 启动服务器
if __name__ == '__main__':
socketio.run(app, host='0.0.0.0', port=5001)
```
### 2. 后端推送示例
```python
# 交易成功后推送通知
socketio.emit('trade_notification', {
'type': 'trade_alert',
'severity': 'success',
'title': '买入成功',
'message': f'买入 {stock_name}({stock_code}) {quantity}股',
'timestamp': int(time.time() * 1000),
'autoClose': 8000
}, room=user_id)
```
---
## 🎨 自定义样式
### 修改通知位置
`src/components/NotificationContainer/index.js`
```javascript
<Box
position="fixed"
bottom={6} // 修改为 top={6} 可以显示在右上角
right={6} // 修改为 left={6} 可以显示在左侧
zIndex={9999}
>
```
### 修改通知颜色
`src/components/NotificationContainer/index.js` 中的 `NOTIFICATION_STYLES`
```javascript
const NOTIFICATION_STYLES = {
success: {
icon: MdCheckCircle,
colorScheme: 'green', // 修改颜色主题
bg: 'green.50',
borderColor: 'green.400',
iconColor: 'green.500',
},
// ...
};
```
---
## 🐛 故障排除
### 问题 1: 通知不显示
**检查项:**
1. 确认 `NotificationProvider` 已包裹应用
2. 检查浏览器控制台是否有错误
3. 确认 socket 连接状态(查看测试工具)
### 问题 2: 音效不播放
**解决方案:**
1. 检查浏览器是否允许自动播放音频
2. 确认音效开关已打开
3. 检查音频文件路径是否正确
### 问题 3: Mock 推送不工作
**检查项:**
1. 确认 `.env` 中设置了 `REACT_APP_ENABLE_MOCK=true`
2. 查看控制台日志确认 Mock 服务已启动
3. 检查 `startMockPush` 是否被调用
### 问题 4: Socket 连接失败
**解决方案:**
1. 检查后端 Flask-SocketIO 是否正确运行
2. 确认 CORS 配置正确
3. 检查 `src/utils/apiConfig.js` 中的 API 地址
---
## 📊 性能优化建议
1. **智能队列管理** ✅ 已实现
- 系统自动限制最多 5 条通知
- 超出自动移除最旧的,避免内存泄漏
- 建议根据实际需求调整 `maxNotifications`
2. **合理设置自动关闭时长**
- 建议 5-10 秒(默认 8 秒)
- 重要消息可设置更长时间或 `autoClose: false`
3. **避免频繁推送**
- 生产环境建议间隔至少 3 秒
- 避免短时间内大量推送造成用户困扰
4. **视觉性能优化** ✅ 已实现
- 使用 Chakra UI 的优化动画Slide、ScaleFade
- z-index 合理分配,避免层叠问题
- 间距适中12px不会过于紧密
---
## 🔮 未来扩展
可以考虑添加的功能:
1. ✨ 通知历史记录
2. ✨ 通知分类过滤
3. ✨ 通知优先级
4. ✨ 通知持久化(存储到 localStorage
5. ✨ 通知点击交互(跳转到相关页面)
6. ✨ 用户偏好设置(通知类型开关)
---
## 📝 更新日志
### v1.1.0 (2025-01-21) - 交互优化版
-**新消息置顶展示**(行业标准,参考 Windows/macOS/Slack
-**智能队列管理**:最多保留 5 条,超出自动移除最旧的
-**视觉层次优化**
- 最新消息boxShadow='2xl' + 边框高亮
- 其他消息boxShadow='lg'
- 消息间距12pxspacing={3}
-**z-index 优化**最新消息层级最高9999依次递减
-**测试工具增强**
- 新增"测试最大限制"按钮6条→5条
- 实时显示队列状态X / 5
- 队列满时红色提示
-**文档完善**:添加展示逻辑说明、配置指南
### v1.0.0 (2025-01-20) - 初始版本
- ✅ 实现基础通知系统
- ✅ 支持 Mock 和真实 Socket.IO 模式
- ✅ 右下角层叠显示
- ✅ 音效提示
- ✅ 开发工具面板
- ✅ 4 种消息类型(成功、错误、警告、信息)
---
## 💡 技术栈
- **前端框架**: React 18.3.1
- **UI 库**: Chakra UI 2.8.2
- **实时通信**: Socket.IO Client 4.7.4
- **后端框架**: Flask-SocketIO 5.3.6
- **状态管理**: React Context API
---
## 📞 支持
如有问题,请查看:
- 项目文档: `CLAUDE.md`
- 测试工具: 开发环境右上角
- 控制台日志: 所有操作都有详细日志
---
**祝你使用愉快!** 🎉

View File

@@ -43,11 +43,14 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
import { NotificationProvider } from "contexts/NotificationContext";
// Components
import ProtectedRoute from "components/ProtectedRoute";
import ErrorBoundary from "components/ErrorBoundary";
import AuthModalManager from "components/Auth/AuthModalManager";
import NotificationContainer from "components/NotificationContainer";
import NotificationTestTool from "components/NotificationTestTool";
import ScrollToTop from "components/ScrollToTop";
import { logger } from "utils/logger";
@@ -190,8 +193,12 @@ export default function App() {
<ErrorBoundary>
<AuthProvider>
<AuthModalProvider>
<AppContent />
<AuthModalManager />
<NotificationProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</NotificationProvider>
</AuthModalProvider>
</AuthProvider>
</ErrorBoundary>

View File

@@ -1,7 +1,7 @@
// src/components/Citation/CitedContent.js
import React from 'react';
import { Typography, Space, Tag } from 'antd';
import { RobotOutlined, FileSearchOutlined } from '@ant-design/icons';
import { Typography, Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import CitationMark from './CitationMark';
import { processCitationData } from '../../utils/citationUtils';
import { logger } from '../../utils/logger';
@@ -9,20 +9,25 @@ import { logger } from '../../utils/logger';
const { Text } = Typography;
/**
* 带引用标注的内容组件
* 带引用标注的内容组件(块级模式)
* 展示拼接的文本每句话后显示上标引用【1】【2】【3】
* 支持鼠标悬浮和点击查看引用来源
* AI 标识统一显示在右上角,不占用布局高度
*
* @param {Object} props
* @param {Object} props.data - API 返回的原始数据 { data: [...] }
* @param {string} props.title - 标题文本,默认 "AI 分析结果"
* @param {boolean} props.showAIBadge - 是否显示 AI 生成标识,默认 true
* @param {string} props.prefix - 内容前的前缀标签,如 "机制:"(可选)
* @param {Object} props.prefixStyle - 前缀标签的自定义样式(可选)
* @param {boolean} props.showAIBadge - 是否显示右上角 AI 标识,默认 true可选
* @param {Object} props.containerStyle - 容器额外样式(可选)
*
* @example
* <CitedContent
* data={apiData}
* title="关联描述"
* prefix="机制:"
* prefixStyle={{ color: '#666' }}
* showAIBadge={true}
* containerStyle={{ marginTop: 16 }}
* />
@@ -30,6 +35,8 @@ const { Text } = Typography;
const CitedContent = ({
data,
title = 'AI 分析结果',
prefix = '',
prefixStyle = {},
showAIBadge = true,
containerStyle = {}
}) => {
@@ -45,59 +52,65 @@ const CitedContent = ({
return null;
}
// 判断是否显示标题栏内联模式title为空且不显示AI徽章
const showHeader = title || showAIBadge;
// 根据是否显示标题栏决定容器样式
const defaultContainerStyle = showHeader ? {
backgroundColor: '#f5f5f5',
borderRadius: 6,
padding: 16
} : {};
// 检查是否为内联模式
const isInlineMode = containerStyle?.display && containerStyle.display.includes('inline');
// 根据内联模式选择容器元素类型
const ContainerTag = isInlineMode ? 'span' : 'div';
const ContentTag = isInlineMode ? 'span' : 'div';
return (
<ContainerTag
<div
style={{
...defaultContainerStyle,
position: 'relative',
width: '100%',
backgroundColor: '#f5f5f5',
borderRadius: 6,
padding: 16,
paddingTop: title ? 16 : 20,
...containerStyle
}}
>
{/* 标题栏 - 仅在需要时显示 */}
{showHeader && (
<Space
{/* AI 标识 - 固定在右上角 */}
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12
position: 'absolute',
top: 12,
right: 12,
margin: 0,
zIndex: 10,
fontSize: 12,
padding: '2px 8px'
}}
className="ai-badge-responsive"
>
<Space>
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
<Text strong style={{ fontSize: 14 }}>
{title}
</Text>
</Space>
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{ margin: 0 }}
>
AI 生成
</Tag>
)}
</Space>
AI合成
</Tag>
)}
{/* 标题栏 */}
{title && (
<div style={{ marginBottom: 12, paddingRight: 80 }}>
<Text strong style={{ fontSize: 14 }}>
{title}
</Text>
</div>
)}
{/* 带引用的文本内容 */}
<ContentTag style={{ lineHeight: isInlineMode ? 'inherit' : 1.8 }}>
<div style={{
lineHeight: 1.8,
paddingRight: title ? 0 : (showAIBadge ? 80 : 0)
}}>
{/* 前缀标签(如果有) */}
{prefix && (
<Text style={{
fontSize: 14,
fontWeight: 'bold',
display: 'inline',
marginRight: 4,
...prefixStyle
}}>
{prefix}
</Text>
)}
{processed.segments.map((segment, index) => (
<React.Fragment key={`segment-${segment.citationId}`}>
{/* 文本片段 */}
@@ -117,8 +130,18 @@ const CitedContent = ({
)}
</React.Fragment>
))}
</ContentTag>
</ContainerTag>
</div>
{/* 响应式样式 */}
<style jsx>{`
@media (max-width: 768px) {
.ai-badge-responsive {
font-size: 10px !important;
padding: 1px 6px !important;
}
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,189 @@
// src/components/NotificationContainer/index.js
/**
* 通知容器组件 - 右下角层叠显示实时通知
*/
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
IconButton,
Icon,
useColorModeValue,
Slide,
ScaleFade,
} from '@chakra-ui/react';
import { MdClose, MdCheckCircle, MdError, MdWarning, MdInfo } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
// 通知类型对应的图标和颜色
const NOTIFICATION_STYLES = {
success: {
icon: MdCheckCircle,
colorScheme: 'green',
bg: 'green.50',
borderColor: 'green.400',
iconColor: 'green.500',
},
error: {
icon: MdError,
colorScheme: 'red',
bg: 'red.50',
borderColor: 'red.400',
iconColor: 'red.500',
},
warning: {
icon: MdWarning,
colorScheme: 'orange',
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
},
info: {
icon: MdInfo,
colorScheme: 'blue',
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
},
};
/**
* 单个通知项组件
*/
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
const { id, severity = 'info', title, message } = notification;
const style = NOTIFICATION_STYLES[severity] || NOTIFICATION_STYLES.info;
const bgColor = useColorModeValue(style.bg, `${style.colorScheme}.900`);
const borderColor = useColorModeValue(style.borderColor, `${style.colorScheme}.500`);
const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.300');
return (
<ScaleFade initialScale={0.9} in={true}>
<Box
bg={bgColor}
borderLeft="4px solid"
borderColor={borderColor}
borderRadius="md"
boxShadow={isNewest ? '2xl' : 'lg'} // 最新消息更强的阴影
p={4}
minW="350px"
maxW="450px"
position="relative"
_hover={{
boxShadow: 'xl',
transform: 'translateX(-4px)',
}}
transition="all 0.2s"
// 最新消息添加微妙的高亮边框
{...(isNewest && {
borderRight: '1px solid',
borderRightColor: borderColor,
borderTop: '1px solid',
borderTopColor: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.700`),
})}
>
<HStack spacing={3} align="start">
{/* 图标 */}
<Icon
as={style.icon}
w={6}
h={6}
color={style.iconColor}
mt={0.5}
flexShrink={0}
/>
{/* 内容 */}
<VStack align="start" spacing={1} flex={1} mr={6}>
<Text
fontSize="md"
fontWeight="bold"
color={textColor}
lineHeight="short"
>
{title}
</Text>
{message && (
<Text
fontSize="sm"
color={subTextColor}
lineHeight="short"
>
{message}
</Text>
)}
</VStack>
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
size="sm"
variant="ghost"
colorScheme={style.colorScheme}
aria-label="关闭通知"
onClick={() => onClose(id)}
position="absolute"
top={2}
right={2}
_hover={{
bg: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.800`),
}}
/>
</HStack>
</Box>
</ScaleFade>
);
};
/**
* 通知容器组件 - 主组件
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
// 如果没有通知,不渲染
if (notifications.length === 0) {
return null;
}
return (
<Box
position="fixed"
bottom={6}
right={6}
zIndex={9999}
pointerEvents="none"
>
<VStack
spacing={3} // 消息之间间距 12px
align="flex-end"
pointerEvents="auto"
>
{notifications.map((notification, index) => (
<Slide
key={notification.id}
direction="right"
in={true}
style={{
position: 'relative',
zIndex: 9999 - index, // 最新消息index=0z-index最高
}}
>
<NotificationItem
notification={notification}
onClose={removeNotification}
isNewest={index === 0} // 第一条消息是最新的
/>
</Slide>
))}
</VStack>
</Box>
);
};
export default NotificationContainer;

View File

@@ -0,0 +1,231 @@
// src/components/NotificationTestTool/index.js
/**
* 通知测试工具 - 仅在开发环境显示
* 用于手动测试通知功能
*/
import React, { useState } from 'react';
import {
Box,
Button,
VStack,
HStack,
Text,
IconButton,
Collapse,
useDisclosure,
Badge,
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { SOCKET_TYPE } from '../../services/socket';
const NotificationTestTool = () => {
const { isOpen, onToggle } = useDisclosure();
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications } = useNotification();
const [testCount, setTestCount] = useState(0);
// 只在开发环境显示
if (process.env.NODE_ENV !== 'development') {
return null;
}
const testNotifications = [
{
severity: 'success',
title: '买入成功',
message: '您的订单已成功执行:买入 贵州茅台(600519) 100股',
},
{
severity: 'error',
title: '委托失败',
message: '卖出订单失败:资金不足',
},
{
severity: 'warning',
title: '价格预警',
message: '您关注的股票已触达预设价格',
},
{
severity: 'info',
title: '持仓提醒',
message: '您持有的股票今日涨幅达 5.2%',
},
];
const handleTestNotification = (index) => {
const notif = testNotifications[index];
addNotification({
...notif,
type: 'trade_alert',
autoClose: 8000,
});
setTestCount(prev => prev + 1);
};
const handleMultipleNotifications = () => {
testNotifications.forEach((notif, index) => {
setTimeout(() => {
addNotification({
...notif,
type: 'trade_alert',
autoClose: 10000,
});
}, index * 600);
});
setTestCount(prev => prev + testNotifications.length);
};
const handleMaxLimitTest = () => {
// 测试最大限制快速发送6条验证只保留最新5条
for (let i = 1; i <= 6; i++) {
setTimeout(() => {
addNotification({
severity: i % 2 === 0 ? 'success' : 'info',
title: `测试消息 #${i}`,
message: `这是第 ${i} 条测试消息共6条应只保留最新5条`,
type: 'trade_alert',
autoClose: 12000,
});
}, i * 400);
}
setTestCount(prev => prev + 6);
};
return (
<Box
position="fixed"
top={4}
right={4}
zIndex={9998}
bg="white"
borderRadius="md"
boxShadow="lg"
overflow="hidden"
>
{/* 折叠按钮 */}
<HStack
p={2}
bg="blue.500"
color="white"
cursor="pointer"
onClick={onToggle}
spacing={2}
>
<MdNotifications size={20} />
<Text fontSize="sm" fontWeight="bold">
通知测试工具
</Text>
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
<Badge colorScheme="purple">
{SOCKET_TYPE}
</Badge>
<IconButton
icon={isOpen ? <MdClose /> : <MdNotifications />}
size="xs"
variant="ghost"
colorScheme="whiteAlpha"
aria-label={isOpen ? '关闭' : '打开'}
/>
</HStack>
{/* 工具面板 */}
<Collapse in={isOpen} animateOpacity>
<VStack p={4} spacing={3} align="stretch" minW="250px">
<Text fontSize="xs" color="gray.600">
点击按钮测试不同类型的通知
</Text>
{/* 测试按钮 */}
<Button
size="sm"
colorScheme="green"
onClick={() => handleTestNotification(0)}
>
成功通知
</Button>
<Button
size="sm"
colorScheme="red"
onClick={() => handleTestNotification(1)}
>
错误通知
</Button>
<Button
size="sm"
colorScheme="orange"
onClick={() => handleTestNotification(2)}
>
警告通知
</Button>
<Button
size="sm"
colorScheme="blue"
onClick={() => handleTestNotification(3)}
>
信息通知
</Button>
<Button
size="sm"
colorScheme="purple"
onClick={handleMultipleNotifications}
>
层叠通知4
</Button>
<Button
size="sm"
colorScheme="pink"
onClick={handleMaxLimitTest}
>
测试最大限制65
</Button>
{/* 功能按钮 */}
<HStack spacing={2}>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={clearAllNotifications}
flex={1}
>
清空全部
</Button>
<IconButton
size="sm"
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
colorScheme={soundEnabled ? 'blue' : 'gray'}
onClick={toggleSound}
aria-label="切换音效"
/>
</HStack>
{/* 统计信息 */}
<VStack spacing={1}>
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
当前队列:
</Text>
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
{notifications.length} / 5
</Badge>
</HStack>
<Text fontSize="xs" color="gray.400" textAlign="center">
已测试: {testCount} 条通知
</Text>
</VStack>
</VStack>
</Collapse>
</Box>
);
};
export default NotificationTestTool;

View File

@@ -553,7 +553,6 @@ const StockChartAntdModal = ({
<CitedContent
data={stock.relation_desc}
title="关联描述"
showAIBadge={true}
containerStyle={{ marginTop: 16 }}
/>
) : stock?.relation_desc ? (

View File

@@ -0,0 +1,207 @@
// src/contexts/NotificationContext.js
/**
* 通知上下文 - 管理实时消息推送和通知显示
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
// 创建通知上下文
const NotificationContext = createContext();
// 自定义Hook
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true);
const audioRef = useRef(null);
// 初始化音频
useEffect(() => {
try {
audioRef.current = new Audio(notificationSound);
audioRef.current.volume = 0.5;
} catch (error) {
logger.error('NotificationContext', 'Audio initialization failed', error);
}
}, []);
/**
* 播放通知音效
*/
const playNotificationSound = useCallback(() => {
if (!soundEnabled || !audioRef.current) {
return;
}
try {
// 重置音频到开始位置
audioRef.current.currentTime = 0;
// 播放音频
audioRef.current.play().catch(error => {
logger.warn('NotificationContext', 'Failed to play notification sound', error);
});
} catch (error) {
logger.error('NotificationContext', 'playNotificationSound', error);
}
}, [soundEnabled]);
/**
* 添加通知到队列
* @param {object} notification - 通知对象
*/
const addNotification = useCallback((notification) => {
const newNotification = {
id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
message: notification.message || '',
timestamp: notification.timestamp || Date.now(),
autoClose: notification.autoClose !== undefined ? notification.autoClose : 8000,
...notification,
};
logger.info('NotificationContext', 'Adding notification', newNotification);
// 新消息插入到数组开头最多保留5条
setNotifications(prev => {
const updated = [newNotification, ...prev];
const maxNotifications = 5;
// 如果超过最大数量,移除最旧的(数组末尾)
if (updated.length > maxNotifications) {
const removed = updated.slice(maxNotifications);
removed.forEach(old => {
logger.info('NotificationContext', 'Auto-removing old notification', { id: old.id });
});
return updated.slice(0, maxNotifications);
}
return updated;
});
// 播放音效
playNotificationSound();
// 自动关闭
if (newNotification.autoClose && newNotification.autoClose > 0) {
setTimeout(() => {
removeNotification(newNotification.id);
}, newNotification.autoClose);
}
return newNotification.id;
}, [playNotificationSound]);
/**
* 移除通知
* @param {string} id - 通知ID
*/
const removeNotification = useCallback((id) => {
logger.info('NotificationContext', 'Removing notification', { id });
setNotifications(prev => prev.filter(notif => notif.id !== id));
}, []);
/**
* 清空所有通知
*/
const clearAllNotifications = useCallback(() => {
logger.info('NotificationContext', 'Clearing all notifications');
setNotifications([]);
}, []);
/**
* 切换音效开关
*/
const toggleSound = useCallback(() => {
setSoundEnabled(prev => {
const newValue = !prev;
logger.info('NotificationContext', 'Sound toggled', { enabled: newValue });
return newValue;
});
}, []);
// 连接到 Socket 服务
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
// 连接 socket
socket.connect();
// 监听连接状态
socket.on('connect', () => {
setIsConnected(true);
logger.info('NotificationContext', 'Socket connected');
// 如果使用 mock可以启动定期推送
if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送每20秒推送1-2条消息
socket.startMockPush(20000, 2);
logger.info('NotificationContext', 'Mock push started');
}
});
socket.on('disconnect', () => {
setIsConnected(false);
logger.warn('NotificationContext', 'Socket disconnected');
});
// 监听交易通知
socket.on('trade_notification', (data) => {
logger.info('NotificationContext', 'Received trade notification', data);
addNotification(data);
});
// 监听系统通知
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
addNotification(data);
});
// 清理函数
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
// 如果是 mock service停止推送
if (SOCKET_TYPE === 'MOCK') {
socket.stopMockPush();
}
socket.off('connect');
socket.off('disconnect');
socket.off('trade_notification');
socket.off('system_notification');
socket.disconnect();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const value = {
notifications,
isConnected,
soundEnabled,
addNotification,
removeNotification,
clearAllNotifications,
toggleSound,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
};
export default NotificationContext;

View File

@@ -372,6 +372,14 @@ export const mockFutureEvents = [
'招商银行',
{
data: [
{
author: '中信证券',
sentences: '作为国内领先的商业银行,招商银行对利率变化敏感度高,美联储货币政策调整将通过汇率、资本流动等渠道影响国内货币政策,进而影响银行净息差和资产质量',
query_part: '美联储政策通过汇率和资本流动影响国内银行业',
report_title: '美联储政策对中国银行业影响分析',
declare_date: '2025-10-18T00:00:00',
match_score: '好'
},
{
author: '中信证券',
sentences: '作为国内领先的商业银行,招商银行对利率变化敏感度高,美联储货币政策调整将通过汇率、资本流动等渠道影响国内货币政策,进而影响银行净息差和资产质量',
@@ -479,6 +487,22 @@ export const mockFutureEvents = [
'天齐锂业',
{
data: [
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
query_part: '核心锂供应商直接受益于下游需求增长',
report_title: '天齐锂业:受益动力电池产业链景气',
declare_date: '2025-10-14T00:00:00',
match_score: '好'
},
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
query_part: '核心锂供应商直接受益于下游需求增长',
report_title: '天齐锂业:受益动力电池产业链景气',
declare_date: '2025-10-14T00:00:00',
match_score: '好'
},
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',

View File

@@ -313,7 +313,7 @@ export const eventHandlers = [
children: [
{
id: '2',
name: '半导体行业',
name: '半导体行业(正向影响)',
transmission_mechanism: {
data: [
{
@@ -323,20 +323,68 @@ export const eventHandlers = [
match_score: "好",
declare_date: "2024-07-10T00:00:00",
report_title: "2024年上半年中国半导体产业发展报告"
},
{
author: "工信部电子信息司",
sentences: "随着5G、人工智能、物联网等新一代信息技术的快速发展半导体作为数字经济的基石正迎来前所未有的发展机遇。预计未来三年国内半导体市场年均增速将保持在25%以上",
query_part: "新兴技术推动半导体产业高速增长",
match_score: "好",
declare_date: "2024-05-20T00:00:00",
report_title: "新一代信息技术产业发展白皮书"
}
]
},
direction: 'positive',
strength: 80,
is_circular: false
},
{
id: '7',
name: '传统制造业(负向影响)',
transmission_mechanism: {
data: [
{
author: "经济观察报",
sentences: "随着半导体等高科技产业获得大量政策和资金支持传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低",
query_part: "资源向高科技倾斜导致传统制造业承压",
match_score: "好",
declare_date: "2024-06-15T00:00:00",
report_title: "传统制造业转型升级调研报告"
}
]
},
direction: 'negative',
strength: 60,
is_circular: false
},
{
id: '8',
name: '能源行业(中性影响)',
transmission_mechanism: {
data: [
{
author: "能源研究所",
sentences: "半导体产业扩张带来电力需求增长约8%但同时推动节能技术应用整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷但智能电网技术应用使能源利用效率提升12%",
query_part: "半导体产业对能源行业影响相对中性",
match_score: "中",
declare_date: "2024-07-01T00:00:00",
report_title: "高科技产业能源消费分析"
}
]
},
direction: 'neutral',
strength: 40,
is_circular: false
},
{
id: '9',
name: '教育培训行业(未明确方向)',
transmission_mechanism: {
data: [
{
author: "教育部职业教育司",
sentences: "半导体产业快速发展催生大量专业人才需求各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人带动职业教育市场规模扩大",
query_part: "半导体产业推动职业教育发展",
match_score: "好",
declare_date: "2024-06-20T00:00:00",
report_title: "半导体人才培养白皮书"
}
]
},
strength: 50,
is_circular: false
}
]
}
@@ -360,6 +408,14 @@ export const eventHandlers = [
name: '主要事件',
transmission_mechanism: {
data: [
{
author: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",
query_part: "新兴应用推动半导体需求增长28%",
match_score: "好",
declare_date: "2024-04-05T00:00:00",
report_title: "2024年Q1中国半导体行业景气度报告"
},
{
author: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",

View File

@@ -0,0 +1,254 @@
// src/services/mockSocketService.js
/**
* Mock Socket 服务 - 用于开发环境模拟实时推送
* 模拟交易提醒、系统通知等实时消息推送
*/
import { logger } from '../utils/logger';
// 模拟交易提醒数据
const mockTradeAlerts = [
{
type: 'trade_alert',
severity: 'success',
title: '买入成功',
message: '您的订单已成功执行:买入 贵州茅台(600519) 100股成交价 ¥1,850.00',
timestamp: Date.now(),
autoClose: 8000,
},
{
type: 'trade_alert',
severity: 'warning',
title: '价格预警',
message: '您关注的股票 比亚迪(002594) 当前价格 ¥245.50,已触达预设价格',
timestamp: Date.now(),
autoClose: 10000,
},
{
type: 'trade_alert',
severity: 'info',
title: '持仓提醒',
message: '您持有的 宁德时代(300750) 今日涨幅达 5.2%,当前盈利 +¥12,350',
timestamp: Date.now(),
autoClose: 8000,
},
{
type: 'trade_alert',
severity: 'error',
title: '委托失败',
message: '卖出订单失败:五粮液(000858) 当前处于停牌状态,无法交易',
timestamp: Date.now(),
autoClose: 12000,
},
{
type: 'system_notification',
severity: 'info',
title: '系统公告',
message: '市场将于15:00收盘请注意及时调整持仓',
timestamp: Date.now(),
autoClose: 10000,
},
{
type: 'trade_alert',
severity: 'success',
title: '分红到账',
message: '您持有的 中国平安(601318) 分红已到账,金额 ¥560.00',
timestamp: Date.now(),
autoClose: 8000,
},
];
class MockSocketService {
constructor() {
this.connected = false;
this.listeners = new Map();
this.intervals = [];
this.messageQueue = [];
}
/**
* 连接到 mock socket
*/
connect() {
if (this.connected) {
logger.warn('mockSocketService', 'Already connected');
return;
}
logger.info('mockSocketService', 'Connecting to mock socket service...');
// 模拟连接延迟
setTimeout(() => {
this.connected = true;
logger.info('mockSocketService', 'Mock socket connected successfully');
// 触发连接成功事件
this.emit('connect', { timestamp: Date.now() });
// 在连接后3秒发送欢迎消息
setTimeout(() => {
this.emit('trade_notification', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
message: '实时消息推送服务已启动 (Mock 模式)',
timestamp: Date.now(),
autoClose: 5000,
});
}, 3000);
}, 1000);
}
/**
* 断开连接
*/
disconnect() {
if (!this.connected) {
return;
}
logger.info('mockSocketService', 'Disconnecting from mock socket service...');
// 清除所有定时器
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.connected = false;
this.emit('disconnect', { timestamp: Date.now() });
}
/**
* 监听事件
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
logger.info('mockSocketService', `Event listener added: ${event}`);
}
/**
* 移除事件监听
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
logger.info('mockSocketService', `Event listener removed: ${event}`);
}
// 如果没有监听器了,删除该事件
if (callbacks.length === 0) {
this.listeners.delete(event);
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('mockSocketService', 'emit', error, { event, data });
}
});
}
/**
* 启动模拟消息推送
* @param {number} interval - 推送间隔(毫秒)
* @param {number} burstCount - 每次推送的消息数量1-3条
*/
startMockPush(interval = 15000, burstCount = 1) {
if (!this.connected) {
logger.warn('mockSocketService', 'Cannot start mock push: not connected');
return;
}
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
const pushInterval = setInterval(() => {
// 随机选择 1-burstCount 条消息
const count = Math.floor(Math.random() * burstCount) + 1;
for (let i = 0; i < count; i++) {
// 从模拟数据中随机选择一条
const randomIndex = Math.floor(Math.random() * mockTradeAlerts.length);
const alert = {
...mockTradeAlerts[randomIndex],
timestamp: Date.now(),
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
};
// 延迟发送(模拟层叠效果)
setTimeout(() => {
this.emit('trade_notification', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
}, interval);
this.intervals.push(pushInterval);
}
/**
* 停止模拟推送
*/
stopMockPush() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
const notification = customData || {
type: 'trade_alert',
severity: 'info',
title: '测试消息',
message: '这是一条手动触发的测试消息',
timestamp: Date.now(),
autoClose: 5000,
id: `test_${Date.now()}`,
};
this.emit('trade_notification', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}
/**
* 获取连接状态
*/
isConnected() {
return this.connected;
}
}
// 导出单例
export const mockSocketService = new MockSocketService();
export default mockSocketService;

View File

@@ -0,0 +1,28 @@
// src/services/socket/index.js
/**
* Socket 服务统一导出
* 根据环境变量自动选择使用 Mock 或真实 Socket.IO 服务
*/
import { mockSocketService } from '../mockSocketService';
import { socketService } from '../socketService';
// 判断是否使用 Mock
const useMock = process.env.REACT_APP_ENABLE_MOCK === 'true' || process.env.REACT_APP_USE_MOCK_SOCKET === 'true';
// 根据环境选择服务
export const socket = useMock ? mockSocketService : socketService;
// 同时导出两个服务,方便测试和调试
export { mockSocketService, socketService };
// 导出服务类型标识
export const SOCKET_TYPE = useMock ? 'MOCK' : 'REAL';
// 打印当前使用的服务类型
console.log(
`%c[Socket Service] Using ${SOCKET_TYPE} Socket Service`,
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
);
export default socket;

View File

@@ -0,0 +1,194 @@
// src/services/socketService.js
/**
* 真实 Socket.IO 服务 - 用于生产环境连接真实后端
*/
import { io } from 'socket.io-client';
import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
const API_BASE_URL = getApiBase();
class SocketService {
constructor() {
this.socket = null;
this.connected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
/**
* 连接到 Socket.IO 服务器
* @param {object} options - 连接选项
*/
connect(options = {}) {
if (this.socket && this.connected) {
logger.warn('socketService', 'Already connected');
return;
}
logger.info('socketService', 'Connecting to Socket.IO server...', { url: API_BASE_URL });
// 创建 socket 连接
this.socket = io(API_BASE_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: this.maxReconnectAttempts,
timeout: 20000,
autoConnect: true,
withCredentials: true, // 允许携带认证信息
...options,
});
// 监听连接成功
this.socket.on('connect', () => {
this.connected = true;
this.reconnectAttempts = 0;
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
});
// 监听断开连接
this.socket.on('disconnect', (reason) => {
this.connected = false;
logger.warn('socketService', 'Socket.IO disconnected', { reason });
});
// 监听连接错误
this.socket.on('connect_error', (error) => {
this.reconnectAttempts++;
logger.error('socketService', 'connect_error', error, {
attempts: this.reconnectAttempts,
maxAttempts: this.maxReconnectAttempts,
});
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
logger.error('socketService', 'Max reconnection attempts reached');
this.socket.close();
}
});
// 监听重连尝试
this.socket.io.on('reconnect_attempt', (attemptNumber) => {
logger.info('socketService', 'Reconnection attempt', { attemptNumber });
});
// 监听重连成功
this.socket.io.on('reconnect', (attemptNumber) => {
this.reconnectAttempts = 0;
logger.info('socketService', 'Reconnected successfully', { attemptNumber });
});
// 监听重连失败
this.socket.io.on('reconnect_failed', () => {
logger.error('socketService', 'Reconnection failed after max attempts');
});
}
/**
* 断开连接
*/
disconnect() {
if (!this.socket) {
return;
}
logger.info('socketService', 'Disconnecting from Socket.IO server...');
this.socket.disconnect();
this.socket = null;
this.connected = false;
}
/**
* 监听事件
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.socket) {
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
return;
}
this.socket.on(event, callback);
logger.info('socketService', `Event listener added: ${event}`);
}
/**
* 移除事件监听
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数(可选)
*/
off(event, callback) {
if (!this.socket) {
return;
}
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
}
logger.info('socketService', `Event listener removed: ${event}`);
}
/**
* 发送消息到服务器
* @param {string} event - 事件名称
* @param {*} data - 发送的数据
* @param {Function} callback - 确认回调(可选)
*/
emit(event, data, callback) {
if (!this.socket || !this.connected) {
logger.warn('socketService', 'Cannot emit: socket not connected', { event, data });
return;
}
if (callback) {
this.socket.emit(event, data, callback);
} else {
this.socket.emit(event, data);
}
logger.info('socketService', `Event emitted: ${event}`, data);
}
/**
* 加入房间
* @param {string} room - 房间名称
*/
joinRoom(room) {
this.emit('join_room', { room });
}
/**
* 离开房间
* @param {string} room - 房间名称
*/
leaveRoom(room) {
this.emit('leave_room', { room });
}
/**
* 获取连接状态
*/
isConnected() {
return this.connected;
}
/**
* 获取 Socket ID
*/
getSocketId() {
return this.socket?.id || null;
}
}
// 导出单例
export const socketService = new SocketService();
export default socketService;

View File

@@ -739,7 +739,6 @@ const InvestmentCalendar = () => {
<CitedContent
data={selectedDetail.content.content}
title={selectedDetail.title || '事件背景'}
showAIBadge={true}
/>
) : (
<div className="markdown-content">

View File

@@ -866,15 +866,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
fontStyle="italic"
>
{selectedNode.extra.description?.data ? (
<>
<CitedContent
data={selectedNode.extra.description}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
AI合成
</>
<CitedContent
data={selectedNode.extra.description}
title=""
/>
) : (
`${selectedNode.extra.description}AI合成`
)}
@@ -916,36 +911,43 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</Text>
<List spacing={2}>
{nodeDetail.parents.map((parent, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="green.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{parent.name}</Text>
{parent.transmission_mechanism?.data ? (
<Text fontSize="xs">
<Text as="span" fontWeight="bold">机制: </Text>
<CitedContent
data={parent.transmission_mechanism}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
AI合成
</Text>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {parent.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
<HStack spacing={2}>
<Badge colorScheme={parent.direction === 'positive' ? 'green' : parent.direction === 'negative' ? 'red' : 'gray'} size="sm">
{parent.direction}
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="green.300" position="relative">
<HStack position="absolute" top={2} right={2} spacing={2} zIndex={1}>
{parent.direction && (
<Badge
colorScheme={
parent.direction === 'positive' ? 'green' :
parent.direction === 'negative' ? 'red' :
'gray'
}
size="sm"
>
{parent.direction === 'positive' ? '正向影响' :
parent.direction === 'negative' ? '负向影响' :
parent.direction === 'neutral' ? '中性影响' : '未知'}
</Badge>
{parent.is_circular && (
<Badge colorScheme="purple" size="sm">🔄 循环</Badge>
)}
</HStack>
)}
{parent.is_circular && (
<Badge colorScheme="purple" size="sm">🔄 循环</Badge>
)}
</HStack>
<VStack align="stretch" spacing={1}>
<Text fontWeight="bold" fontSize="sm" pr={parent.direction || parent.is_circular ? 20 : 0}>{parent.name}</Text>
{parent.transmission_mechanism?.data ? (
<CitedContent
data={parent.transmission_mechanism}
title=""
prefix="机制:"
prefixStyle={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}
containerStyle={{ marginTop: 8 }}
showAIBadge={false}
/>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {parent.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
</ListItem>
))}
</List>
@@ -967,33 +969,40 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</Text>
<List spacing={2}>
{nodeDetail.children.map((child, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300">
<HStack justify="space-between" align="flex-start">
<VStack align="stretch" spacing={1} flex={1}>
<Text fontWeight="bold" fontSize="sm">{child.name}</Text>
{child.transmission_mechanism?.data ? (
<Text fontSize="xs">
<Text as="span" fontWeight="bold">机制: </Text>
<CitedContent
data={child.transmission_mechanism}
title=""
showAIBadge={false}
containerStyle={{ backgroundColor: 'transparent', padding: 0, display: 'inline-block', verticalAlign: 'baseline' }}
/>
AI合成
</Text>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {child.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
<HStack spacing={2}>
<Badge colorScheme={child.direction === 'positive' ? 'green' : child.direction === 'negative' ? 'red' : 'gray'} size="sm">
{child.direction}
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300" position="relative">
{child.direction && (
<Box position="absolute" top={2} right={2} zIndex={1}>
<Badge
colorScheme={
child.direction === 'positive' ? 'green' :
child.direction === 'negative' ? 'red' :
'gray'
}
size="sm"
>
{child.direction === 'positive' ? '正向影响' :
child.direction === 'negative' ? '负向影响' :
child.direction === 'neutral' ? '中性影响' : '未知'}
</Badge>
</HStack>
</HStack>
</Box>
)}
<VStack align="stretch" spacing={1}>
<Text fontWeight="bold" fontSize="sm" pr={child.direction ? 20 : 0}>{child.name}</Text>
{child.transmission_mechanism?.data ? (
<CitedContent
data={child.transmission_mechanism}
title=""
prefix="机制:"
prefixStyle={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}
containerStyle={{ marginTop: 8 }}
showAIBadge={false}
/>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
机制: {child.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
</ListItem>
))}
</List>