feat: 添加消息推送能力,添加新闻催化分析页的合规提示
This commit is contained in:
@@ -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
455
NOTIFICATION_SYSTEM.md
Normal 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'
|
||||
- 消息间距: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`
|
||||
- 测试工具: 开发环境右上角
|
||||
- 控制台日志: 所有操作都有详细日志
|
||||
|
||||
---
|
||||
|
||||
**祝你使用愉快!** 🎉
|
||||
11
src/App.js
11
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() {
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<AppContent />
|
||||
<AuthModalManager />
|
||||
<NotificationProvider>
|
||||
<AppContent />
|
||||
<AuthModalManager />
|
||||
<NotificationContainer />
|
||||
<NotificationTestTool />
|
||||
</NotificationProvider>
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
189
src/components/NotificationContainer/index.js
Normal file
189
src/components/NotificationContainer/index.js
Normal 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=0)z-index最高
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0} // 第一条消息是最新的
|
||||
/>
|
||||
</Slide>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationContainer;
|
||||
231
src/components/NotificationTestTool/index.js
Normal file
231
src/components/NotificationTestTool/index.js
Normal 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}
|
||||
>
|
||||
测试最大限制(6条→5条)
|
||||
</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;
|
||||
@@ -553,7 +553,6 @@ const StockChartAntdModal = ({
|
||||
<CitedContent
|
||||
data={stock.relation_desc}
|
||||
title="关联描述"
|
||||
showAIBadge={true}
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
/>
|
||||
) : stock?.relation_desc ? (
|
||||
|
||||
207
src/contexts/NotificationContext.js
Normal file
207
src/contexts/NotificationContext.js
Normal 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;
|
||||
@@ -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: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
|
||||
|
||||
@@ -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%,创历史新高",
|
||||
|
||||
254
src/services/mockSocketService.js
Normal file
254
src/services/mockSocketService.js
Normal 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;
|
||||
28
src/services/socket/index.js
Normal file
28
src/services/socket/index.js
Normal 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;
|
||||
194
src/services/socketService.js
Normal file
194
src/services/socketService.js
Normal 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;
|
||||
@@ -739,7 +739,6 @@ const InvestmentCalendar = () => {
|
||||
<CitedContent
|
||||
data={selectedDetail.content.content}
|
||||
title={selectedDetail.title || '事件背景'}
|
||||
showAIBadge={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-content">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user