diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 790e08c6..1316e97e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/NOTIFICATION_SYSTEM.md b/NOTIFICATION_SYSTEM.md new file mode 100644 index 00000000..c1210635 --- /dev/null +++ b/NOTIFICATION_SYSTEM.md @@ -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 ( +
+

连接状态: {isConnected ? '已连接' : '未连接'}

+ +
+ ); +} +``` + +### 消息格式 + +```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 +// 调整消息间距 + // 3 = 12px, 4 = 16px, 2 = 8px + +// 调整位置 + +``` + +--- + +## 🌐 后端集成(生产环境) + +### 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 + +``` + +### 修改通知颜色 + +在 `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' + - 消息间距:12px(spacing={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` +- 测试工具: 开发环境右上角 +- 控制台日志: 所有操作都有详细日志 + +--- + +**祝你使用愉快!** 🎉 diff --git a/src/App.js b/src/App.js index ed8c6aac..8ff59802 100755 --- a/src/App.js +++ b/src/App.js @@ -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() { - - + + + + + + diff --git a/src/components/Citation/CitedContent.js b/src/components/Citation/CitedContent.js index aa100671..a9726ab3 100644 --- a/src/components/Citation/CitedContent.js +++ b/src/components/Citation/CitedContent.js @@ -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 * @@ -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 ( - - {/* 标题栏 - 仅在需要时显示 */} - {showHeader && ( - } + 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" > - - - - {title} - - - {showAIBadge && ( - } - color="purple" - style={{ margin: 0 }} - > - AI 生成 - - )} - + AI合成 + + )} + + {/* 标题栏 */} + {title && ( +
+ + {title} + +
)} {/* 带引用的文本内容 */} - +
+ {/* 前缀标签(如果有) */} + {prefix && ( + + {prefix} + + )} + {processed.segments.map((segment, index) => ( {/* 文本片段 */} @@ -117,8 +130,18 @@ const CitedContent = ({ )} ))} - - +
+ + {/* 响应式样式 */} + + ); }; diff --git a/src/components/NotificationContainer/index.js b/src/components/NotificationContainer/index.js new file mode 100644 index 00000000..3c2a962c --- /dev/null +++ b/src/components/NotificationContainer/index.js @@ -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 ( + + + + {/* 图标 */} + + + {/* 内容 */} + + + {title} + + {message && ( + + {message} + + )} + + + {/* 关闭按钮 */} + } + 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`), + }} + /> + + + + ); +}; + +/** + * 通知容器组件 - 主组件 + */ +const NotificationContainer = () => { + const { notifications, removeNotification } = useNotification(); + + // 如果没有通知,不渲染 + if (notifications.length === 0) { + return null; + } + + return ( + + + {notifications.map((notification, index) => ( + + + + ))} + + + ); +}; + +export default NotificationContainer; diff --git a/src/components/NotificationTestTool/index.js b/src/components/NotificationTestTool/index.js new file mode 100644 index 00000000..3aa75741 --- /dev/null +++ b/src/components/NotificationTestTool/index.js @@ -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 ( + + {/* 折叠按钮 */} + + + + 通知测试工具 + + + {isConnected ? 'Connected' : 'Disconnected'} + + + {SOCKET_TYPE} + + : } + size="xs" + variant="ghost" + colorScheme="whiteAlpha" + aria-label={isOpen ? '关闭' : '打开'} + /> + + + {/* 工具面板 */} + + + + 点击按钮测试不同类型的通知 + + + {/* 测试按钮 */} + + + + + + + + + + + + + {/* 功能按钮 */} + + + + : } + colorScheme={soundEnabled ? 'blue' : 'gray'} + onClick={toggleSound} + aria-label="切换音效" + /> + + + {/* 统计信息 */} + + + + 当前队列: + + = 5 ? 'red' : 'blue'}> + {notifications.length} / 5 + + + + 已测试: {testCount} 条通知 + + + + + + ); +}; + +export default NotificationTestTool; diff --git a/src/components/StockChart/StockChartAntdModal.js b/src/components/StockChart/StockChartAntdModal.js index 70068505..2f84ef90 100644 --- a/src/components/StockChart/StockChartAntdModal.js +++ b/src/components/StockChart/StockChartAntdModal.js @@ -553,7 +553,6 @@ const StockChartAntdModal = ({ ) : stock?.relation_desc ? ( diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js new file mode 100644 index 00000000..b91cf7e3 --- /dev/null +++ b/src/contexts/NotificationContext.js @@ -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 ( + + {children} + + ); +}; + +export default NotificationContext; diff --git a/src/mocks/data/account.js b/src/mocks/data/account.js index c4cab478..cd0fba60 100644 --- a/src/mocks/data/account.js +++ b/src/mocks/data/account.js @@ -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: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强', diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index a263a1ba..4c3f2a48 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -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%,创历史新高", diff --git a/src/services/mockSocketService.js b/src/services/mockSocketService.js new file mode 100644 index 00000000..65ddef65 --- /dev/null +++ b/src/services/mockSocketService.js @@ -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; diff --git a/src/services/socket/index.js b/src/services/socket/index.js new file mode 100644 index 00000000..dfecdad3 --- /dev/null +++ b/src/services/socket/index.js @@ -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; diff --git a/src/services/socketService.js b/src/services/socketService.js new file mode 100644 index 00000000..32e129f0 --- /dev/null +++ b/src/services/socketService.js @@ -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; diff --git a/src/views/Community/components/InvestmentCalendar.js b/src/views/Community/components/InvestmentCalendar.js index 9c6966c3..cac3d1f9 100644 --- a/src/views/Community/components/InvestmentCalendar.js +++ b/src/views/Community/components/InvestmentCalendar.js @@ -739,7 +739,6 @@ const InvestmentCalendar = () => { ) : (
diff --git a/src/views/EventDetail/components/TransmissionChainAnalysis.js b/src/views/EventDetail/components/TransmissionChainAnalysis.js index 62d412d5..92006cfb 100644 --- a/src/views/EventDetail/components/TransmissionChainAnalysis.js +++ b/src/views/EventDetail/components/TransmissionChainAnalysis.js @@ -866,15 +866,10 @@ const TransmissionChainAnalysis = ({ eventId }) => { fontStyle="italic" > {selectedNode.extra.description?.data ? ( - <> - - (AI合成) - + ) : ( `${selectedNode.extra.description}(AI合成)` )} @@ -916,36 +911,43 @@ const TransmissionChainAnalysis = ({ eventId }) => { {nodeDetail.parents.map((parent, index) => ( - - - - {parent.name} - {parent.transmission_mechanism?.data ? ( - - 机制: - - (AI合成) - - ) : parent.transmission_mechanism ? ( - - 机制: {parent.transmission_mechanism}(AI合成) - - ) : null} - - - - {parent.direction} + + + {parent.direction && ( + + {parent.direction === 'positive' ? '正向影响' : + parent.direction === 'negative' ? '负向影响' : + parent.direction === 'neutral' ? '中性影响' : '未知'} - {parent.is_circular && ( - 🔄 循环 - )} - + )} + {parent.is_circular && ( + 🔄 循环 + )} + + {parent.name} + {parent.transmission_mechanism?.data ? ( + + ) : parent.transmission_mechanism ? ( + + 机制: {parent.transmission_mechanism}(AI合成) + + ) : null} + ))} @@ -967,33 +969,40 @@ const TransmissionChainAnalysis = ({ eventId }) => { {nodeDetail.children.map((child, index) => ( - - - - {child.name} - {child.transmission_mechanism?.data ? ( - - 机制: - - (AI合成) - - ) : child.transmission_mechanism ? ( - - 机制: {child.transmission_mechanism}(AI合成) - - ) : null} - - - - {child.direction} + + {child.direction && ( + + + {child.direction === 'positive' ? '正向影响' : + child.direction === 'negative' ? '负向影响' : + child.direction === 'neutral' ? '中性影响' : '未知'} - - + + )} + + {child.name} + {child.transmission_mechanism?.data ? ( + + ) : child.transmission_mechanism ? ( + + 机制: {child.transmission_mechanism}(AI合成) + + ) : null} + ))}