diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 1316e97e..726f6789 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -1,7 +1,9 @@
{
"permissions": {
"allow": [
- "Read(//Users/qiye/**)"
+ "Read(//Users/qiye/**)",
+ "Bash(npm run lint:check)",
+ "Bash(npm run build)"
],
"deny": [],
"ask": []
diff --git a/.gitignore b/.gitignore
index 9ed08676..392de6f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,11 @@ pnpm-debug.log*
.DS_Store
# Windows
-Thumbs.dbsrc/assets/img/original-backup/
+Thumbs.db
+
+# Documentation
+*.md
+!README.md
+!CLAUDE.md
+
+src/assets/img/original-backup/
diff --git a/DARK_MODE_TEST.md b/DARK_MODE_TEST.md
new file mode 100644
index 00000000..965b0a2c
--- /dev/null
+++ b/DARK_MODE_TEST.md
@@ -0,0 +1,307 @@
+# 🌙 暗色模式适配 - 测试指南
+
+## ✅ 完成的修改
+
+### 修改文件
+
+1. **`src/constants/notificationTypes.js`** - 添加暗色模式配置
+2. **`src/components/NotificationContainer/index.js`** - 更新颜色逻辑
+
+### 新增配置
+
+为每种通知类型添加了暗色模式专属配置:
+
+| 配置项 | 亮色值 | 暗色值 | 说明 |
+|-------|-------|-------|------|
+| `bg` | `{color}.50` | `rgba(..., 0.15)` | 背景色:15% 透明度 |
+| `borderColor` | `{color}.400` | `{color}.400` | 边框色:保持一致 |
+| `iconColor` | `{color}.500` | `{color}.300` | 图标色:降低饱和度 |
+| `hoverBg` | `{color}.100` | `rgba(..., 0.25)` | Hover背景:25% 透明度 |
+
+---
+
+## 🧪 测试步骤
+
+### 1. 启动应用
+
+```bash
+npm start
+```
+
+### 2. 切换到暗色模式
+
+#### 方法 A:通过浏览器开发者工具
+
+1. 打开浏览器开发者工具(F12)
+2. 切换到 "渲染" 或 "Rendering" 标签
+3. 找到 "Emulate CSS media feature prefers-color-scheme"
+4. 选择 "prefers-color-scheme: dark"
+
+#### 方法 B:系统设置
+
+1. 将你的操作系统切换到暗色模式
+2. 刷新页面
+
+#### 方法 C:Chakra UI Color Mode Toggle
+
+如果你的应用有主题切换按钮,直接点击切换即可。
+
+### 3. 触发通知
+
+**Mock 模式**(默认):
+- 等待 60 秒,会自动推送 1-2 条通知
+- 或在控制台执行:
+ ```javascript
+ import { mockSocketService } from './services/mockSocketService.js';
+ mockSocketService.sendTestNotification();
+ ```
+
+**Real 模式**:
+- 创建测试事件(运行后端测试脚本)
+
+### 4. 验证效果
+
+检查以下项目:
+
+#### ✅ 背景色
+- [ ] **半透明效果**:背景应该是半透明的,能看到底层背景
+- [ ] **类型区分**:蓝、橙、紫、红、绿应该清晰可辨
+- [ ] **不刺眼**:不应该有过深的背景色
+
+#### ✅ 文字颜色
+- [ ] **主标题**:`gray.100`(浅灰,不是纯白)
+- [ ] **副文本**:`gray.300`(更淡的灰)
+- [ ] **元信息**:`gray.500`(中等灰)
+
+#### ✅ 图标颜色
+- [ ] 图标应该是 `.300` 色阶(柔和但清晰)
+- [ ] 不同类型有不同颜色
+
+#### ✅ 边框
+- [ ] 边框清晰可见(`.400` 色阶)
+- [ ] 保持类型区分
+
+#### ✅ Hover 效果
+- [ ] 鼠标悬停时背景加深(25% 透明度)
+- [ ] 有平滑过渡动画
+
+---
+
+## 🎨 视觉对比
+
+### 亮色模式
+```
+┌─────────────────────────────────┐
+│ 🔵 蓝色浅背景 (blue.50) │
+│ 深色文字 (gray.800) │
+│ 明亮图标 (blue.500) │
+│ 边框清晰 (blue.400) │
+└─────────────────────────────────┘
+```
+
+### 暗色模式(修改后)
+```
+┌─────────────────────────────────┐
+│ 🔵 半透明蓝背景 (15% opacity) │
+│ 浅灰文字 (gray.100) │
+│ 柔和图标 (blue.300) │
+│ 边框可见 (blue.400) │
+└─────────────────────────────────┘
+```
+
+---
+
+## 📋 各类型通知配色
+
+### 公告通知(蓝色)
+- **亮色**:`blue.50` 背景
+- **暗色**:`rgba(59, 130, 246, 0.15)` 半透明蓝
+
+### 股票涨(红色)
+- **亮色**:`red.50` 背景
+- **暗色**:`rgba(239, 68, 68, 0.15)` 半透明红
+
+### 股票跌(绿色)
+- **亮色**:`green.50` 背景
+- **暗色**:`rgba(34, 197, 94, 0.15)` 半透明绿
+
+### 事件动向(橙色)
+- **亮色**:`orange.50` 背景
+- **暗色**:`rgba(249, 115, 22, 0.15)` 半透明橙
+
+### 分析报告(紫色)
+- **亮色**:`purple.50` 背景
+- **暗色**:`rgba(168, 85, 247, 0.15)` 半透明紫
+
+---
+
+## 🔍 在浏览器控制台测试
+
+### 手动触发各类型通知
+
+```javascript
+// 引入服务
+import { mockSocketService } from './services/mockSocketService.js';
+import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from './constants/notificationTypes.js';
+
+// 测试公告通知(蓝色)
+mockSocketService.sendTestNotification({
+ type: NOTIFICATION_TYPES.ANNOUNCEMENT,
+ priority: PRIORITY_LEVELS.IMPORTANT,
+ title: '测试公告通知',
+ content: '这是暗色模式下的蓝色通知',
+ timestamp: Date.now(),
+ autoClose: 0,
+});
+
+// 测试股票上涨(红色)
+mockSocketService.sendTestNotification({
+ type: NOTIFICATION_TYPES.STOCK_ALERT,
+ priority: PRIORITY_LEVELS.URGENT,
+ title: '测试股票上涨',
+ content: '宁德时代 +5.2%',
+ extra: { priceChange: '+5.2%' },
+ timestamp: Date.now(),
+ autoClose: 0,
+});
+
+// 测试股票下跌(绿色)
+mockSocketService.sendTestNotification({
+ type: NOTIFICATION_TYPES.STOCK_ALERT,
+ priority: PRIORITY_LEVELS.IMPORTANT,
+ title: '测试股票下跌',
+ content: '比亚迪 -3.8%',
+ extra: { priceChange: '-3.8%' },
+ timestamp: Date.now(),
+ autoClose: 0,
+});
+
+// 测试事件动向(橙色)
+mockSocketService.sendTestNotification({
+ type: NOTIFICATION_TYPES.EVENT_ALERT,
+ priority: PRIORITY_LEVELS.IMPORTANT,
+ title: '测试事件动向',
+ content: '央行宣布降准',
+ timestamp: Date.now(),
+ autoClose: 0,
+});
+
+// 测试分析报告(紫色)
+mockSocketService.sendTestNotification({
+ type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
+ priority: PRIORITY_LEVELS.NORMAL,
+ title: '测试分析报告',
+ content: '医药行业深度报告',
+ timestamp: Date.now(),
+ autoClose: 0,
+});
+```
+
+---
+
+## 🐛 常见问题
+
+### Q: 暗色模式下还是很深?
+
+**A:** 检查配置是否正确应用:
+1. 清除浏览器缓存并刷新
+2. 确认 `notificationTypes.js` 包含 `darkBg` 等配置
+3. 在控制台查看元素的实际 `background` 值
+
+### Q: 不同类型看起来都一样?
+
+**A:** 确认:
+1. 透明度配置是否生效(应该看到半透明效果)
+2. 不同类型的 RGB 值是否不同
+3. 浏览器是否支持 `rgba()` 颜色
+
+### Q: 文字看不清?
+
+**A:** 调整文字颜色:
+- 主标题:`gray.100`(可调整为 `gray.50` 或 `white`)
+- 如果背景太淡,可以增加透明度(15% → 20%)
+
+### Q: 如何微调透明度?
+
+**A:** 在 `notificationTypes.js` 中修改 `rgba()` 的第 4 个参数:
+```javascript
+darkBg: 'rgba(59, 130, 246, 0.20)', // 从 0.15 改为 0.20
+```
+
+---
+
+## 🎯 预期效果截图对比
+
+### 亮色模式下的通知
+- 背景明亮(.50 色阶)
+- 文字深色(gray.800)
+- 图标鲜艳(.500 色阶)
+
+### 暗色模式下的通知
+- 背景半透明(15% 透明度)
+- 文字浅色(gray.100)
+- 图标柔和(.300 色阶)
+- **保持类型区分度**
+
+---
+
+## 📊 技术参数
+
+### 透明度参数
+
+| 状态 | 透明度 | 说明 |
+|-----|-------|------|
+| 默认 | 15% | 背景色 |
+| Hover | 25% | 鼠标悬停 |
+
+### 色阶选择
+
+| 元素 | 亮色 | 暗色 | 原因 |
+|-----|------|------|------|
+| 背景 | .50 | rgba 15% | 保持通透感 |
+| 边框 | .400 | .400 | 确保可见 |
+| 图标 | .500 | .300 | 降低饱和度 |
+| 文字 | .800 | .100 | 保持对比度 |
+
+---
+
+## ✅ 测试检查清单
+
+- [ ] 亮色模式下通知正常显示
+- [ ] 暗色模式下通知半透明效果
+- [ ] 5 种类型(蓝、红、绿、橙、紫)区分清晰
+- [ ] 文字在暗色背景上可读性良好
+- [ ] 图标颜色柔和但醒目
+- [ ] Hover 效果明显
+- [ ] 边框清晰可见
+- [ ] 亮色/暗色切换平滑
+
+---
+
+## 🚀 如果需要调整
+
+如果效果不满意,可以调整以下参数:
+
+### 调整透明度(`notificationTypes.js`)
+
+```javascript
+// 增加对比度(背景更明显)
+darkBg: 'rgba(59, 130, 246, 0.25)', // 15% → 25%
+
+// 减少对比度(更柔和)
+darkBg: 'rgba(59, 130, 246, 0.10)', // 15% → 10%
+```
+
+### 调整文字颜色(`NotificationContainer/index.js`)
+
+```javascript
+// 更亮的文字
+const textColor = useColorModeValue('gray.800', 'gray.50'); // gray.100 → gray.50
+
+// 更柔和的文字
+const textColor = useColorModeValue('gray.800', 'gray.200'); // gray.100 → gray.200
+```
+
+---
+
+**测试完成后,请反馈效果!** 🎉
diff --git a/ENHANCED_FEATURES_GUIDE.md b/ENHANCED_FEATURES_GUIDE.md
new file mode 100644
index 00000000..9fc726a5
--- /dev/null
+++ b/ENHANCED_FEATURES_GUIDE.md
@@ -0,0 +1,626 @@
+# 通知系统增强功能 - 使用指南
+
+## 📋 概述
+
+本指南介绍通知系统的三大增强功能:
+1. **智能桌面通知** - 自动请求权限,系统级通知
+2. **性能监控** - 追踪推送效果,数据驱动优化
+3. **历史记录** - 持久化存储,随时查询
+
+---
+
+## 🎯 功能 1:智能桌面通知
+
+### 功能说明
+
+首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
+
+### 工作原理
+
+```javascript
+// 在 NotificationContext 中的逻辑
+if (priority === URGENT || priority === IMPORTANT) {
+ if (browserPermission === 'default' && !hasRequestedPermission) {
+ // 首次遇到重要通知,自动请求权限
+ await requestBrowserPermission();
+ setHasRequestedPermission(true); // 避免重复请求
+ }
+}
+```
+
+### 权限状态
+
+- **granted**: 已授权,可以发送桌面通知
+- **denied**: 已拒绝,无法发送桌面通知
+- **default**: 未请求,首次重要通知时会自动请求
+
+### 使用示例
+
+**自动触发**(推荐)
+```javascript
+// 无需任何代码,系统自动处理
+// 首次收到重要/紧急通知时会自动弹出权限请求
+```
+
+**手动请求**
+```javascript
+import { useNotification } from 'contexts/NotificationContext';
+
+function SettingsPage() {
+ const { requestBrowserPermission, browserPermission } = useNotification();
+
+ return (
+
+
当前状态: {browserPermission}
+
+
+ );
+}
+```
+
+### 通知分发策略
+
+| 优先级 | 页面在前台 | 页面在后台 |
+|-------|----------|----------|
+| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
+| 重要 | 网页通知 | 桌面通知 |
+| 普通 | 网页通知 | 网页通知 |
+
+### 测试步骤
+
+1. **清除已保存的权限状态**
+ ```javascript
+ localStorage.removeItem('browser_notification_requested');
+ ```
+
+2. **刷新页面**
+
+3. **触发一个重要/紧急通知**
+ - Mock 模式:等待自动推送
+ - Real 模式:创建测试事件
+
+4. **观察权限请求弹窗**
+ - 浏览器会弹出通知权限请求
+ - 点击"允许"授权
+
+5. **验证桌面通知**
+ - 切换到其他标签页
+ - 收到重要通知时应该看到桌面通知
+
+---
+
+## 📊 功能 2:性能监控
+
+### 功能说明
+
+追踪通知推送的各项指标,包括:
+- **到达率**: 发送 vs 接收
+- **点击率**: 点击 vs 接收
+- **响应时间**: 收到通知到点击的平均时间
+- **类型分布**: 各类型通知的数量和效果
+- **时段分布**: 每小时推送量
+
+### API 参考
+
+#### 获取汇总统计
+
+```javascript
+import { notificationMetricsService } from 'services/notificationMetricsService';
+
+const summary = notificationMetricsService.getSummary();
+console.log(summary);
+/* 输出:
+{
+ totalSent: 100,
+ totalReceived: 98,
+ totalClicked: 45,
+ totalDismissed: 53,
+ avgResponseTime: 5200, // 毫秒
+ clickRate: '45.92', // 百分比
+ deliveryRate: '98.00' // 百分比
+}
+*/
+```
+
+#### 获取按类型统计
+
+```javascript
+const byType = notificationMetricsService.getByType();
+console.log(byType);
+/* 输出:
+{
+ announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
+ stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
+ event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
+ analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
+}
+*/
+```
+
+#### 获取按优先级统计
+
+```javascript
+const byPriority = notificationMetricsService.getByPriority();
+console.log(byPriority);
+/* 输出:
+{
+ urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
+ important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
+ normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
+}
+*/
+```
+
+#### 获取每日数据
+
+```javascript
+const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
+console.log(dailyData);
+/* 输出:
+[
+ { date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
+ { date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
+ ...
+]
+*/
+```
+
+#### 获取完整指标
+
+```javascript
+const allMetrics = notificationMetricsService.getAllMetrics();
+console.log(allMetrics);
+```
+
+#### 导出数据
+
+```javascript
+// 导出为 JSON
+const json = notificationMetricsService.exportToJSON();
+console.log(json);
+
+// 导出为 CSV
+const csv = notificationMetricsService.exportToCSV();
+console.log(csv);
+```
+
+#### 重置指标
+
+```javascript
+notificationMetricsService.reset();
+```
+
+### 在控制台查看实时指标
+
+打开浏览器控制台,执行:
+
+```javascript
+// 引入服务
+import { notificationMetricsService } from './services/notificationMetricsService.js';
+
+// 查看汇总
+console.table(notificationMetricsService.getSummary());
+
+// 查看按类型分布
+console.table(notificationMetricsService.getByType());
+
+// 查看最近 7 天数据
+console.table(notificationMetricsService.getDailyData(7));
+```
+
+### 监控埋点(自动)
+
+监控服务已自动集成到 `NotificationContext`,无需手动调用:
+
+- **trackReceived**: 收到通知时自动调用
+- **trackClicked**: 点击通知时自动调用
+- **trackDismissed**: 关闭通知时自动调用
+
+### 可视化展示(可选)
+
+你可以基于监控数据创建仪表板:
+
+```javascript
+import { notificationMetricsService } from 'services/notificationMetricsService';
+import { PieChart, LineChart } from 'recharts';
+
+function MetricsDashboard() {
+ const summary = notificationMetricsService.getSummary();
+ const dailyData = notificationMetricsService.getDailyData(7);
+ const byType = notificationMetricsService.getByType();
+
+ return (
+
+ {/* 汇总卡片 */}
+
+
+
+
+ {/* 类型分布饼图 */}
+
({
+ name: type,
+ value: data.received
+ }))} />
+
+ {/* 每日趋势折线图 */}
+
+
+ );
+}
+```
+
+---
+
+## 📜 功能 3:历史记录
+
+### 功能说明
+
+持久化存储所有接收到的通知,支持:
+- 查询和筛选
+- 搜索关键词
+- 标记已读/已点击
+- 批量删除
+- 导出(JSON/CSV)
+
+### API 参考
+
+#### 获取历史记录(支持筛选和分页)
+
+```javascript
+import { notificationHistoryService } from 'services/notificationHistoryService';
+
+const result = notificationHistoryService.getHistory({
+ type: 'event_alert', // 可选:筛选类型
+ priority: 'urgent', // 可选:筛选优先级
+ readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
+ startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
+ endDate: Date.now(), // 可选:结束日期
+ page: 1, // 页码
+ pageSize: 20, // 每页数量
+});
+
+console.log(result);
+/* 输出:
+{
+ records: [...], // 当前页的记录
+ total: 150, // 总记录数
+ page: 1, // 当前页
+ pageSize: 20, // 每页数量
+ totalPages: 8 // 总页数
+}
+*/
+```
+
+#### 搜索历史记录
+
+```javascript
+const results = notificationHistoryService.searchHistory('降准');
+console.log(results); // 返回标题/内容中包含"降准"的所有记录
+```
+
+#### 标记已读/已点击
+
+```javascript
+// 标记已读
+notificationHistoryService.markAsRead('notification_id');
+
+// 标记已点击
+notificationHistoryService.markAsClicked('notification_id');
+```
+
+#### 删除记录
+
+```javascript
+// 删除单条
+notificationHistoryService.deleteRecord('notification_id');
+
+// 批量删除
+notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
+
+// 清空所有
+notificationHistoryService.clearHistory();
+```
+
+#### 获取统计数据
+
+```javascript
+const stats = notificationHistoryService.getStats();
+console.log(stats);
+/* 输出:
+{
+ total: 500, // 总记录数
+ read: 320, // 已读数
+ unread: 180, // 未读数
+ clicked: 150, // 已点击数
+ clickRate: '30.00', // 点击率
+ byType: { // 按类型统计
+ announcement: 100,
+ stock_alert: 150,
+ event_alert: 200,
+ analysis_report: 50
+ },
+ byPriority: { // 按优先级统计
+ urgent: 50,
+ important: 200,
+ normal: 250
+ }
+}
+*/
+```
+
+#### 导出历史记录
+
+```javascript
+// 导出为 JSON 字符串
+const json = notificationHistoryService.exportToJSON({
+ type: 'event_alert' // 可选:只导出特定类型
+});
+
+// 导出为 CSV 字符串
+const csv = notificationHistoryService.exportToCSV();
+
+// 直接下载 JSON 文件
+notificationHistoryService.downloadJSON();
+
+// 直接下载 CSV 文件
+notificationHistoryService.downloadCSV();
+```
+
+### 在控制台使用
+
+打开浏览器控制台,执行:
+
+```javascript
+// 引入服务
+import { notificationHistoryService } from './services/notificationHistoryService.js';
+
+// 查看所有历史
+console.table(notificationHistoryService.getHistory().records);
+
+// 搜索
+const results = notificationHistoryService.searchHistory('央行');
+console.table(results);
+
+// 查看统计
+console.table(notificationHistoryService.getStats());
+
+// 导出并下载
+notificationHistoryService.downloadJSON();
+```
+
+### 数据结构
+
+每条历史记录包含:
+
+```javascript
+{
+ id: 'notif_123', // 通知 ID
+ notification: { // 完整通知对象
+ type: 'event_alert',
+ priority: 'urgent',
+ title: '...',
+ content: '...',
+ ...
+ },
+ receivedAt: 1737459600000, // 接收时间戳
+ readAt: 1737459650000, // 已读时间戳(null 表示未读)
+ clickedAt: null, // 已点击时间戳(null 表示未点击)
+}
+```
+
+### 存储限制
+
+- **最大数量**: 500 条(超过后自动删除最旧的)
+- **存储位置**: localStorage
+- **容量估算**: 约 2-5MB(取决于通知内容长度)
+
+---
+
+## 🔧 技术细节
+
+### 文件结构
+
+```
+src/
+├── services/
+│ ├── browserNotificationService.js [已存在] 浏览器通知服务
+│ ├── notificationMetricsService.js [新建] 性能监控服务
+│ └── notificationHistoryService.js [新建] 历史记录服务
+├── contexts/
+│ └── NotificationContext.js [修改] 集成所有功能
+└── components/
+ └── NotificationContainer/
+ └── index.js [修改] 添加点击追踪
+```
+
+### 修改清单
+
+| 文件 | 修改内容 | 状态 |
+|------|---------|------|
+| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
+| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
+| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
+| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
+
+### 数据流
+
+```
+用户收到通知
+ ↓
+NotificationContext.addWebNotification()
+ ├─ notificationMetricsService.trackReceived() [监控埋点]
+ ├─ notificationHistoryService.saveNotification() [历史保存]
+ ├─ 首次重要通知 → requestBrowserPermission() [智能权限]
+ └─ 显示网页通知或桌面通知
+
+用户点击通知
+ ↓
+NotificationContainer.handleClick()
+ ├─ notificationMetricsService.trackClicked() [监控埋点]
+ ├─ notificationHistoryService.markAsClicked() [历史标记]
+ └─ 跳转到目标页面
+
+用户关闭通知
+ ↓
+NotificationContext.removeNotification()
+ └─ notificationMetricsService.trackDismissed() [监控埋点]
+```
+
+---
+
+## 🧪 测试步骤
+
+### 1. 测试智能桌面通知
+
+```bash
+# 1. 清除已保存的权限状态
+localStorage.removeItem('browser_notification_requested');
+
+# 2. 刷新页面
+
+# 3. 等待或触发一个重要/紧急通知
+
+# 4. 观察浏览器弹出权限请求
+
+# 5. 授权后验证桌面通知功能
+```
+
+### 2. 测试性能监控
+
+```javascript
+// 在控制台执行
+import { notificationMetricsService } from './services/notificationMetricsService.js';
+
+// 查看实时统计
+console.table(notificationMetricsService.getSummary());
+
+// 模拟推送几条通知,再次查看
+console.table(notificationMetricsService.getAllMetrics());
+
+// 导出数据
+console.log(notificationMetricsService.exportToJSON());
+```
+
+### 3. 测试历史记录
+
+```javascript
+// 在控制台执行
+import { notificationHistoryService } from './services/notificationHistoryService.js';
+
+// 查看历史
+console.table(notificationHistoryService.getHistory().records);
+
+// 搜索
+console.table(notificationHistoryService.searchHistory('降准'));
+
+// 查看统计
+console.table(notificationHistoryService.getStats());
+
+// 导出
+notificationHistoryService.downloadJSON();
+```
+
+---
+
+## 📈 数据导出示例
+
+### 导出性能监控数据
+
+```javascript
+import { notificationMetricsService } from 'services/notificationMetricsService';
+
+// 导出 JSON
+const json = notificationMetricsService.exportToJSON();
+// 复制到剪贴板或保存
+
+// 导出 CSV
+const csv = notificationMetricsService.exportToCSV();
+// 可以在 Excel 中打开
+```
+
+### 导出历史记录
+
+```javascript
+import { notificationHistoryService } from 'services/notificationHistoryService';
+
+// 导出最近 7 天的事件动向通知
+const json = notificationHistoryService.exportToJSON({
+ type: 'event_alert',
+ startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
+});
+
+// 直接下载为文件
+notificationHistoryService.downloadJSON({
+ type: 'event_alert'
+});
+```
+
+---
+
+## ⚠️ 注意事项
+
+### 1. localStorage 容量限制
+
+- 大多数浏览器限制为 5-10MB
+- 建议定期清理历史记录和监控数据
+- 使用导出功能备份数据
+
+### 2. 浏览器兼容性
+
+- **桌面通知**: 需要 HTTPS 或 localhost
+- **localStorage**: 所有现代浏览器支持
+- **权限请求**: 需要用户交互(不能自动授权)
+
+### 3. 隐私和数据安全
+
+- 所有数据存储在本地(localStorage)
+- 不会上传到服务器
+- 用户可以随时清空数据
+
+### 4. 性能影响
+
+- 监控埋点非常轻量,几乎无性能影响
+- 历史记录保存异步进行,不阻塞 UI
+- 数据查询在客户端完成,不增加服务器负担
+
+---
+
+## 🎉 总结
+
+### 已实现的功能
+
+✅ **智能桌面通知**
+- 首次重要通知时自动请求权限
+- 智能分发策略(前台/后台)
+- localStorage 持久化权限状态
+
+✅ **性能监控**
+- 到达率、点击率、响应时间追踪
+- 按类型、优先级、时段统计
+- 数据导出(JSON/CSV)
+
+✅ **历史记录**
+- 持久化存储(最多 500 条)
+- 筛选、搜索、分页
+- 已读/已点击标记
+- 数据导出(JSON/CSV)
+
+### 未实现的功能(备份,待上线)
+
+⏸️ 历史记录页面 UI(代码已备份,随时可上线)
+⏸️ 监控仪表板 UI(可选,暂未实现)
+
+### 下一步建议
+
+1. **用户设置页面**: 允许用户自定义通知偏好
+2. **声音提示**: 为紧急通知添加音效
+3. **数据同步**: 将历史和监控数据同步到服务器
+4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
+
+---
+
+**文档版本**: v1.0
+**最后更新**: 2025-01-21
+**维护者**: Claude Code
diff --git a/MESSAGE_PUSH_INTEGRATION_TEST.md b/MESSAGE_PUSH_INTEGRATION_TEST.md
new file mode 100644
index 00000000..a368b484
--- /dev/null
+++ b/MESSAGE_PUSH_INTEGRATION_TEST.md
@@ -0,0 +1,370 @@
+# 消息推送系统整合 - 测试指南
+
+## 📋 整合完成清单
+
+✅ **统一事件名称**
+- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
+- 移除了 `trade_notification` 事件名
+
+✅ **数据适配器**
+- 创建了 `adaptEventToNotification` 函数
+- 自动识别后端事件格式并转换为前端通知格式
+- 重要性映射:S → urgent, A → important, B/C → normal
+
+✅ **NotificationContext 升级**
+- 监听 `new_event` 事件
+- 自动使用适配器转换事件数据
+- 支持 Mock 和 Real 模式无缝切换
+
+✅ **EventList 实时推送**
+- 集成 `useEventNotifications` Hook
+- 实时更新事件列表
+- Toast 通知提示
+- WebSocket 连接状态指示器
+
+---
+
+## 🧪 测试步骤
+
+### 1. 测试 Mock 模式(开发环境)
+
+#### 1.1 配置环境变量
+确保 `.env` 文件包含以下配置:
+```bash
+REACT_APP_USE_MOCK_SOCKET=true
+# 或者
+REACT_APP_ENABLE_MOCK=true
+```
+
+#### 1.2 启动应用
+```bash
+npm start
+```
+
+#### 1.3 验证功能
+
+**a) 右下角通知卡片**
+- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
+- 每隔 60 秒会自动推送 1-2 条模拟消息
+- 通知类型包括:
+ - 📢 公告通知(蓝色)
+ - 📈 股票动向(红/绿色,根据涨跌)
+ - 📰 事件动向(橙色)
+ - 📊 分析报告(紫色)
+
+**b) 事件列表页面**
+- 访问事件列表页面(Community/Events)
+- 顶部应显示 "🟢 实时推送已开启"
+- 收到新事件时:
+ - 右上角显示 Toast 通知
+ - 事件自动添加到列表顶部
+ - 无重复添加
+
+**c) 控制台日志**
+打开浏览器控制台,应该看到:
+```
+[Socket Service] Using MOCK Socket Service
+NotificationContext: Socket connected
+EventList: 收到新事件推送
+```
+
+---
+
+### 2. 测试 Real 模式(生产环境)
+
+#### 2.1 配置环境变量
+修改 `.env` 文件:
+```bash
+REACT_APP_USE_MOCK_SOCKET=false
+# 或删除该配置项
+```
+
+#### 2.2 启动后端 Flask 服务
+```bash
+python app_2.py
+```
+
+确保后端已启动 Socket.IO 服务并监听事件推送。
+
+#### 2.3 启动前端应用
+```bash
+npm start
+```
+
+#### 2.4 创建测试事件(后端)
+使用后端提供的测试脚本:
+```bash
+python test_create_event.py
+```
+
+#### 2.5 验证功能
+
+**a) WebSocket 连接**
+- 检查控制台:`[Socket Service] Using REAL Socket Service`
+- 事件列表顶部显示 "🟢 实时推送已开启"
+
+**b) 事件推送流程**
+1. 运行 `test_create_event.py` 创建新事件
+2. 后端轮询检测到新事件(最多等待 30 秒)
+3. 后端通过 Socket.IO 推送 `new_event`
+4. 前端接收事件并转换格式
+5. 同时显示:
+ - 右下角通知卡片
+ - 事件列表 Toast 提示
+ - 事件添加到列表顶部
+
+**c) 数据格式验证**
+在控制台查看事件对象,应包含:
+```javascript
+{
+ id: 123,
+ type: "event_alert", // 适配器转换后
+ priority: "urgent", // importance: S → urgent
+ title: "事件标题",
+ content: "事件描述",
+ clickable: true,
+ link: "/event-detail/123",
+ extra: {
+ eventType: "tech",
+ importance: "S",
+ // ... 更多后端字段
+ }
+}
+```
+
+---
+
+## 🔍 验证清单
+
+### 功能验证
+
+- [ ] Mock 模式下收到模拟通知
+- [ ] Real 模式下收到真实后端推送
+- [ ] 通知卡片正确显示(类型、颜色、内容)
+- [ ] 事件列表实时更新
+- [ ] Toast 通知正常弹出
+- [ ] 连接状态指示器正确显示
+- [ ] 点击通知可跳转到详情页
+- [ ] 无重复事件添加
+
+### 数据验证
+
+- [ ] 后端事件格式正确转换
+- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
+- [ ] 时间戳正确显示
+- [ ] 链接路径正确生成
+- [ ] 所有字段完整保留在 extra 中
+
+### 性能验证
+
+- [ ] 事件列表最多保留 100 条
+- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
+- [ ] WebSocket 自动重连
+- [ ] 无内存泄漏
+
+---
+
+## 🐛 常见问题排查
+
+### Q1: Mock 模式下没有收到通知?
+**A:** 检查:
+1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
+2. 控制台是否显示 "Using MOCK Socket Service"
+3. 是否等待了 3 秒(首次通知延迟)
+
+### Q2: Real 模式下无法连接?
+**A:** 检查:
+1. Flask 后端是否启动:`python app_2.py`
+2. API_BASE_URL 是否正确配置
+3. CORS 设置是否包含前端域名
+4. 控制台是否有连接错误
+
+### Q3: 收到重复通知?
+**A:** 检查:
+1. 是否多次渲染了 EventList 组件
+2. 是否在多个地方调用了 `useEventNotifications`
+3. 控制台日志中是否有 "事件已存在,跳过添加"
+
+### Q4: 通知卡片样式异常?
+**A:** 检查:
+1. 事件的 `type` 字段是否正确
+2. 是否缺少必要的字段(title, content)
+3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
+
+### Q5: 事件列表不更新?
+**A:** 检查:
+1. WebSocket 连接状态(顶部 Badge)
+2. `onNewEvent` 回调是否触发(控制台日志)
+3. `setLocalEvents` 是否正确执行
+
+---
+
+## 📊 测试数据示例
+
+### Mock 模拟数据类型
+
+**公告通知**
+```javascript
+{
+ type: "announcement",
+ priority: "urgent",
+ title: "贵州茅台发布2024年度财报公告",
+ content: "2024年度营收同比增长15.2%..."
+}
+```
+
+**股票动向**
+```javascript
+{
+ type: "stock_alert",
+ priority: "urgent",
+ title: "您关注的股票触发预警",
+ extra: {
+ stockCode: "300750",
+ priceChange: "+5.2%"
+ }
+}
+```
+
+**事件动向**
+```javascript
+{
+ type: "event_alert",
+ priority: "important",
+ title: "央行宣布降准0.5个百分点",
+ extra: {
+ eventId: "evt001",
+ sectors: ["银行", "地产", "基建"]
+ }
+}
+```
+
+**分析报告**
+```javascript
+{
+ type: "analysis_report",
+ priority: "important",
+ title: "医药行业深度报告:创新药迎来政策拐点",
+ author: {
+ name: "李明",
+ organization: "中信证券"
+ }
+}
+```
+
+### 真实后端事件格式
+
+```javascript
+{
+ id: 123,
+ title: "新能源汽车补贴政策延期",
+ description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
+ event_type: "policy",
+ importance: "S",
+ status: "active",
+ created_at: "2025-01-21T14:30:00",
+ hot_score: 95.5,
+ view_count: 1234,
+ related_avg_chg: 5.2,
+ related_max_chg: 15.8,
+ keywords: ["新能源", "补贴", "政策"]
+}
+```
+
+---
+
+## 🎯 下一步建议
+
+### 1. 用户设置
+允许用户控制通知偏好:
+```jsx
+
+ 启用实时通知
+
+```
+
+### 2. 通知过滤
+按重要性、类型过滤通知:
+```javascript
+useEventNotifications({
+ eventType: 'tech', // 只订阅科技类
+ importance: 'S', // 只订阅 S 级
+ enabled: true
+})
+```
+
+### 3. 声音提示
+添加音效提醒:
+```javascript
+onNewEvent: (event) => {
+ if (event.priority === 'urgent') {
+ new Audio('/alert.mp3').play();
+ }
+}
+```
+
+### 4. 桌面通知
+利用浏览器通知 API:
+```javascript
+if (Notification.permission === 'granted') {
+ new Notification(event.title, {
+ body: event.content,
+ icon: '/logo.png'
+ });
+}
+```
+
+---
+
+## 📝 技术说明
+
+### 架构优势
+
+1. **统一接口**:Mock 和 Real 完全相同的 API
+2. **自动适配**:智能识别数据格式并转换
+3. **解耦设计**:通知系统和事件列表独立工作
+4. **向后兼容**:不影响现有功能
+
+### 关键文件
+
+- `src/services/mockSocketService.js` - Mock Socket 服务
+- `src/services/socketService.js` - 真实 Socket.IO 服务
+- `src/services/socket/index.js` - 统一导出
+- `src/contexts/NotificationContext.js` - 通知上下文(含适配器)
+- `src/hooks/useEventNotifications.js` - React Hook
+- `src/views/Community/components/EventList.js` - 事件列表集成
+
+### 数据流
+
+```
+后端创建事件
+ ↓
+后端轮询检测(30秒)
+ ↓
+Socket.IO 推送 new_event
+ ↓
+前端 socketService 接收
+ ↓
+NotificationContext 监听并适配
+ ↓
+同时触发:
+ ├─ NotificationContainer(右下角卡片)
+ └─ EventList onNewEvent(Toast + 列表更新)
+```
+
+---
+
+## ✅ 整合完成
+
+所有代码和功能已经就绪!你现在可以:
+
+1. ✅ 在 Mock 模式下测试实时推送
+2. ✅ 在 Real 模式下连接后端
+3. ✅ 查看右下角通知卡片
+4. ✅ 体验事件列表实时更新
+5. ✅ 随时切换 Mock/Real 模式
+
+**祝测试顺利!🎉**
diff --git a/NOTIFICATION_OPTIMIZATION_SUMMARY.md b/NOTIFICATION_OPTIMIZATION_SUMMARY.md
new file mode 100644
index 00000000..2317a8a3
--- /dev/null
+++ b/NOTIFICATION_OPTIMIZATION_SUMMARY.md
@@ -0,0 +1,280 @@
+# 消息推送系统优化总结
+
+## 优化目标
+1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
+2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
+3. 采用智能显示策略,降低普通通知的视觉干扰
+
+## 实施内容
+
+### 1. 优先级配置更新 (src/constants/notificationTypes.js)
+
+#### 新增配置项
+- `borderWidth`: 边框宽度
+ - 紧急 (urgent): 6px
+ - 重要 (important): 4px
+ - 普通 (normal): 2px
+
+- `bgOpacity`: 背景色透明度(亮色模式)
+ - 紧急: 0.25 (深色背景)
+ - 重要: 0.15 (中色背景)
+ - 普通: 0.08 (浅色背景)
+
+- `darkBgOpacity`: 背景色透明度(暗色模式)
+ - 紧急: 0.30
+ - 重要: 0.20
+ - 普通: 0.12
+
+#### 新增辅助函数
+- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
+- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
+
+### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
+
+#### 动画效果
+- 使用 `@emotion/react` 的 `keyframes` 创建脉冲动画
+- 仅紧急通知 (urgent) 应用动画效果
+- 动画特性:
+ - 边框颜色脉冲效果
+ - 阴影扩散效果(0 → 12px)
+ - 持续时间:2秒
+ - 缓动函数:ease-in-out
+ - 无限循环
+
+```javascript
+const pulseAnimation = keyframes`
+ 0%, 100% {
+ border-left-color: currentColor;
+ box-shadow: 0 0 0 0 currentColor;
+ }
+ 50% {
+ border-left-color: currentColor;
+ box-shadow: -4px 0 12px 0 currentColor;
+ }
+`;
+```
+
+### 3. 背景色优先级优化
+
+#### 亮色模式
+- **紧急通知**:`${colorScheme}.200` - 深色背景 + 脉冲动画
+- **重要通知**:`${colorScheme}.100` - 中色背景
+- **普通通知**:`white` - 极淡背景(降低视觉干扰)
+
+#### 暗色模式
+- **紧急通知**:`${colorScheme}.800` 或 typeConfig.darkBg
+- **重要通知**:`${colorScheme}.800` 或 typeConfig.darkBg
+- **普通通知**:`gray.800` - 暗灰背景(降低视觉干扰)
+
+### 4. 可点击性视觉提示
+
+#### 问题
+- 用户需要 hover 才能知道通知是否可点击
+- cursor: pointer 不够直观
+
+#### 解决方案
+- **可点击的通知**:
+ - 添加完整边框(四周 1px solid)
+ - 保持左侧优先级边框宽度
+ - 使用更明显的阴影(md 级别)
+ - 产生微妙的悬浮感
+
+- **不可点击的通知**:
+ - 仅左侧边框
+ - 使用较淡的阴影(sm 级别)
+
+```javascript
+// 可点击的通知添加完整边框
+{...(isActuallyClickable && {
+ border: '1px solid',
+ borderLeftWidth: priorityBorderWidth, // 保持优先级
+})}
+
+// 可点击的通知使用更明显的阴影
+boxShadow={isActuallyClickable
+ ? (isNewest ? '2xl' : 'md')
+ : (isNewest ? 'xl' : 'sm')}
+```
+
+### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
+
+#### 显示元素分级
+
+**LV1 - 必需元素(始终显示)**
+- ✅ 标题 (title)
+- ✅ 内容 (content, 最多3行)
+- ✅ 时间 (publishTime/pushTime)
+- ✅ 查看详情 (仅当 clickable=true 时)
+- ✅ 关闭按钮
+
+**LV2 - 可选元素(数据存在时显示)**
+- ✅ 图标:仅在紧急/重要通知时显示
+- ❌ 优先级标签:已移除,改用边框+背景色表示
+- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
+
+**LV3 - 可选元素(数据存在时显示)**
+- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
+- ✅ 预测标识:仅当 `isPrediction = true` 时显示
+
+**其他**
+- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
+
+#### 优先级视觉样式
+- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
+- ✅ 背景色深度:根据优先级使用不同深度的颜色
+ - 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
+ - 暗色模式: 使用 typeConfig 的 darkBg 配置
+
+#### 布局优化
+- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
+- ✅ 无图标时不添加额外的左侧间距
+
+## 预期效果
+
+### 视觉改进
+- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
+- **优先级强化**:
+ - 紧急通知:6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
+ - 重要通知:4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
+ - 普通通知:2px 细边框 + 白色/极淡背景 → 低视觉干扰
+- **可点击性一目了然**:
+ - 可点击:完整边框 + 明显阴影 → 卡片悬浮感
+ - 不可点击:仅左侧边框 + 淡阴影 → 平面感
+- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
+
+### 用户体验
+- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
+- **快速识别优先级**:
+ - 动画 = 紧急(需要立即关注)
+ - 图标 + 粗边框 = 重要(需要关注)
+ - 细边框 + 淡背景 = 普通(可稍后查看)
+- **可点击性无需 hover**:
+ - 完整边框 + 悬浮感 = 可以点击查看详情
+ - 仅左侧边框 = 信息已完整,无需跳转
+- **智能显示**:可选信息只在数据存在时显示,避免空白占位
+- **响应式优化**:所有设备上保持一致的显示逻辑
+
+### 向后兼容
+- ✅ 完全兼容现有通知数据结构
+- ✅ 可选字段不存在时自动隐藏
+- ✅ 不影响现有功能(点击、关闭、自动消失等)
+
+## 测试建议
+
+### 1. 功能测试
+```bash
+# 启动开发服务器
+npm start
+
+# 观察不同优先级通知的显示效果
+# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
+# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
+# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
+```
+
+### 1.1 动画测试
+- [ ] 紧急通知的脉冲动画流畅无卡顿
+- [ ] 动画周期为 2 秒
+- [ ] 动画在紧急通知显示期间持续循环
+- [ ] 阴影扩散效果清晰可见
+
+### 2. 边界测试
+- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
+- [ ] 包含所有可选字段的通知
+- [ ] 不同类型的通知(公告、股票、事件、分析报告)
+- [ ] 不同优先级的通知(紧急、重要、普通)
+
+### 3. 响应式测试
+- [ ] 移动设备 (< 480px)
+- [ ] 平板设备 (480px - 768px)
+- [ ] 桌面设备 (> 768px)
+
+### 4. 暗色模式测试
+- [ ] 切换到暗色模式,确认背景色对比度合适
+
+## 技术细节
+
+### 关键代码变更
+
+#### 1. 脉冲动画实现
+```javascript
+// 导入 keyframes
+import { keyframes } from '@emotion/react';
+
+// 定义脉冲动画
+const pulseAnimation = keyframes`
+ 0%, 100% {
+ border-left-color: currentColor;
+ box-shadow: 0 0 0 0 currentColor;
+ }
+ 50% {
+ border-left-color: currentColor;
+ box-shadow: -4px 0 12px 0 currentColor;
+ }
+`;
+
+// 应用到紧急通知
+
+```
+
+#### 2. 优先级标签自动隐藏
+```javascript
+// PRIORITY_CONFIGS 中所有 show 属性设置为 false
+show: false, // 不再显示标签,改用边框+背景色表示
+```
+
+#### 3. 背景色优先级优化
+```javascript
+const getPriorityBgColor = () => {
+ const colorScheme = typeConfig.colorScheme;
+ if (!isDark) {
+ if (priority === PRIORITY_LEVELS.URGENT) {
+ return `${colorScheme}.200`; // 深色背景 + 脉冲动画
+ } else if (priority === PRIORITY_LEVELS.IMPORTANT) {
+ return `${colorScheme}.100`; // 中色背景
+ } else {
+ return 'white'; // 极淡背景(降低视觉干扰)
+ }
+ } else {
+ if (priority === PRIORITY_LEVELS.URGENT) {
+ return typeConfig.darkBg || `${colorScheme}.800`;
+ } else if (priority === PRIORITY_LEVELS.IMPORTANT) {
+ return typeConfig.darkBg || `${colorScheme}.800`;
+ } else {
+ return 'gray.800'; // 暗灰背景(降低视觉干扰)
+ }
+ }
+};
+```
+
+#### 4. 图标条件显示
+```javascript
+const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
+ priority === PRIORITY_LEVELS.IMPORTANT;
+
+{shouldShowIcon && (
+
+)}
+};
+```
+
+## 后续改进建议
+
+### 短期
+- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
+- [ ] 提供配置选项让用户自定义显示元素
+
+### 长期
+- [ ] 支持通知分组(按类型或优先级)
+- [ ] 添加通知搜索和筛选功能
+- [ ] 通知历史记录可视化统计
+
+## 构建状态
+✅ 构建成功 (npm run build)
+✅ 无语法错误
+✅ 无 TypeScript 错误
diff --git a/NOTIFICATION_SYSTEM.md b/NOTIFICATION_SYSTEM.md
index 964e857b..a3f348e1 100644
--- a/NOTIFICATION_SYSTEM.md
+++ b/NOTIFICATION_SYSTEM.md
@@ -1,6 +1,79 @@
# 实时消息推送系统使用指南
-## 🆕 最新更新 (v2.3.0 - 通知体验优化)
+## 🆕 最新更新 (v2.10.0 - 点击加载反馈)
+
+- ✅ **按钮加载态**:点击"查看详情"后按钮显示 loading spinner,文字变为"跳转中..."(蓝色)
+- ✅ **防重复点击**:加载状态时禁用再次点击,cursor 变为 wait,避免误操作
+- ✅ **延迟关闭**:跳转后延迟 300ms 关闭通知,给用户足够的视觉反馈
+- ✅ **整卡禁用**:加载时通知变半透明(opacity 0.7),禁用所有交互(pointerEvents: none)
+- ✅ **流畅体验**:使用 Chakra UI Spinner (size="xs") 匹配图标大小,视觉一致
+- ✅ **状态一致性**:Loading 时 hover 效果禁用,确保用户知道正在跳转
+
+---
+
+### v2.9.0 更新回顾
+
+- ✅ **头部简化**:移除 AI 和预测标签,只保留优先级标签(紧急/重要),避免换行拥挤
+- ✅ **底部补充**:AI 和预测标识移到底部元数据区,使用 xs size 小徽章,信息不丢失
+- ✅ **预测状态合并**:预测徽章与状态提示("详细报告生成中...")合并显示,更紧凑
+- ✅ **响应式优化**:移动端底部只显示时间和操作提示,AI/预测标识在小屏及以上显示
+- ✅ **元数据顺序调整**:时间优先,然后AI、预测、作者、操作提示,信息层次清晰
+- ✅ **视觉平衡**:头部更简洁清爽,优先级标签更醒目,辅助信息完整保留
+
+---
+
+### v2.8.0 更新回顾
+
+- ✅ **ARIA 完全支持**:为所有通知添加完整的 ARIA 属性(role、aria-live、aria-atomic、aria-label)
+- ✅ **智能角色分配**:紧急通知使用 role="alert" + aria-live="assertive",其他使用 role="status" + aria-live="polite"
+- ✅ **键盘导航**:可点击通知支持 Tab 键聚焦,Enter/Space 键打开详情
+- ✅ **屏幕阅读器优化**:自动生成完整描述(类型、优先级、标题、内容、时间、操作提示)
+- ✅ **焦点管理**:添加蓝色聚焦轮廓(2px solid blue.500),符合 WCAG 2.1 AA 标准
+- ✅ **关闭按钮增强**:支持键盘操作,aria-label 包含通知标题
+- ✅ **展开/收起按钮**:aria-expanded 状态提示,描述性 aria-label
+- ✅ **容器区域标识**:通知中心使用 role="region",动态更新统计信息
+
+---
+
+### v2.7.0 更新回顾
+
+- ✅ **智能动画**:只对新增通知应用动画,已存在通知直接显示
+- ✅ **移除双层嵌套**:去除 ScaleFade,只保留 Slide 动画
+- ✅ **展开/收起优化**:展开15条通知无动画,避免卡顿
+- ✅ **性能提升90%+**:动画数量从30个减少到1-2个
+- ✅ **GPU 加速**:添加 willChange CSS 优化
+
+---
+
+### v2.6.0 更新回顾
+
+- ✅ **状态持久化**:展开/收起状态保存到 localStorage,刷新页面后保持
+- ✅ **2分钟自动过期**:展开状态2分钟后自动重置为收起,避免长期展开
+- ✅ **跨标签页实时同步**:多个标签页展开/收起状态自动同步
+- ✅ **智能过期检查**:每10秒自动检查过期,无需刷新页面
+- ✅ **优雅降级**:localStorage 不可用时仍正常工作
+
+---
+
+### v2.5.0 更新回顾
+
+- ✅ **避开设置按钮**:桌面端底部偏移112px,完全避开右下角设置按钮冲突
+- ✅ **响应式偏移**:移动端保持12px贴近底部,桌面端112px留足空间
+- ✅ **视觉优化**:通知位置更合理,不再遮挡其他固定元素
+
+---
+
+### v2.4.0 更新回顾
+
+- ✅ **响应式宽度**:移动端屏宽-32px,小屏360px,平板380px,桌面400px
+- ✅ **响应式信息密度**:移动端精简显示(标题1行、内容2行),桌面完整显示
+- ✅ **响应式元数据**:移动端仅显示时间,小屏+状态,平板+作者信息
+- ✅ **响应式间距**:移动端12px边距,平板+24px边距
+- ✅ **完美适配**:在手机、平板、桌面各种设备上都能舒适阅读
+
+---
+
+### v2.3.0 更新回顾
- ✅ **按优先级区分自动关闭**:紧急通知不自动关闭,重要30秒,普通15秒
- ✅ **简单折叠机制**:最多显示3条通知,超过显示"还有X条"展开按钮
diff --git a/src/App.js b/src/App.js
index 8ff59802..54f7dfe3 100755
--- a/src/App.js
+++ b/src/App.js
@@ -43,22 +43,49 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
-import { NotificationProvider } from "contexts/NotificationContext";
+import { NotificationProvider, useNotification } 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 ConnectionStatusBar from "components/ConnectionStatusBar";
import NotificationTestTool from "components/NotificationTestTool";
import ScrollToTop from "components/ScrollToTop";
import { logger } from "utils/logger";
+/**
+ * ConnectionStatusBar 包装组件
+ * 需要在 NotificationProvider 内部使用,所以单独提取
+ */
+function ConnectionStatusBarWrapper() {
+ const { connectionStatus, reconnectAttempt, retryConnection } = useNotification();
+
+ const handleClose = () => {
+ // 关闭状态条(可选,当前不实现)
+ // 用户可以通过刷新页面来重新显示
+ };
+
+ return (
+
+ );
+}
+
function AppContent() {
const { colorMode } = useColorMode();
return (
+ {/* Socket 连接状态条 */}
+
+
{/* 路由切换时自动滚动到顶部 */}
diff --git a/src/components/ConnectionStatusBar/index.js b/src/components/ConnectionStatusBar/index.js
new file mode 100644
index 00000000..b609aaec
--- /dev/null
+++ b/src/components/ConnectionStatusBar/index.js
@@ -0,0 +1,132 @@
+// src/components/ConnectionStatusBar/index.js
+/**
+ * Socket 连接状态栏组件
+ * 显示 Socket 连接状态并提供重试功能
+ */
+
+import React from 'react';
+import {
+ Alert,
+ AlertIcon,
+ AlertTitle,
+ AlertDescription,
+ Button,
+ CloseButton,
+ Box,
+ HStack,
+ useColorModeValue,
+ Slide,
+} from '@chakra-ui/react';
+import { MdRefresh } from 'react-icons/md';
+
+/**
+ * 连接状态枚举
+ */
+export const CONNECTION_STATUS = {
+ CONNECTED: 'connected', // 已连接
+ DISCONNECTED: 'disconnected', // 已断开
+ RECONNECTING: 'reconnecting', // 重连中
+ FAILED: 'failed', // 连接失败
+};
+
+/**
+ * 连接状态栏组件
+ */
+const ConnectionStatusBar = ({
+ status = CONNECTION_STATUS.CONNECTED,
+ reconnectAttempt = 0,
+ maxReconnectAttempts = 5,
+ onRetry,
+ onClose,
+}) => {
+ // 仅在非正常状态时显示
+ const shouldShow = status !== CONNECTION_STATUS.CONNECTED;
+
+ // 状态配置
+ const statusConfig = {
+ [CONNECTION_STATUS.DISCONNECTED]: {
+ status: 'warning',
+ title: '连接已断开',
+ description: '正在尝试重新连接...',
+ },
+ [CONNECTION_STATUS.RECONNECTING]: {
+ status: 'warning',
+ title: '正在重新连接',
+ description: `尝试重连中 (第 ${reconnectAttempt}/${maxReconnectAttempts} 次)`,
+ },
+ [CONNECTION_STATUS.FAILED]: {
+ status: 'error',
+ title: '连接失败',
+ description: '无法连接到服务器,请检查网络连接',
+ },
+ };
+
+ const config = statusConfig[status] || statusConfig[CONNECTION_STATUS.DISCONNECTED];
+
+ // 颜色配置
+ const bg = useColorModeValue(
+ {
+ warning: 'orange.50',
+ error: 'red.50',
+ }[config.status],
+ {
+ warning: 'orange.900',
+ error: 'red.900',
+ }[config.status]
+ );
+
+ return (
+
+
+
+
+
+
+ {config.title}
+
+
+ {config.description}
+
+
+
+
+ {/* 重试按钮(仅失败状态显示) */}
+ {status === CONNECTION_STATUS.FAILED && onRetry && (
+ }
+ onClick={onRetry}
+ mr={2}
+ flexShrink={0}
+ >
+ 立即重试
+
+ )}
+
+ {/* 关闭按钮(仅失败状态显示) */}
+ {status === CONNECTION_STATUS.FAILED && onClose && (
+
+ )}
+
+
+ );
+};
+
+export default ConnectionStatusBar;
diff --git a/src/components/NotificationContainer/index.js b/src/components/NotificationContainer/index.js
index 777aeb8c..0b818b17 100644
--- a/src/components/NotificationContainer/index.js
+++ b/src/components/NotificationContainer/index.js
@@ -3,7 +3,7 @@
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/
-import React, { useState } from 'react';
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
@@ -14,25 +14,249 @@ import {
Icon,
Badge,
Button,
+ Spinner,
useColorModeValue,
- Slide,
- ScaleFade,
} from '@chakra-ui/react';
-import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess } from 'react-icons/md';
+import { keyframes } from '@emotion/react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess, MdPerson, MdAccessTime } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import {
NOTIFICATION_TYPE_CONFIGS,
NOTIFICATION_TYPES,
PRIORITY_CONFIGS,
+ PRIORITY_LEVELS,
NOTIFICATION_CONFIG,
formatNotificationTime,
+ getPriorityBgOpacity,
+ getPriorityBorderWidth,
} from '../../constants/notificationTypes';
/**
- * 单个通知项组件
+ * 自定义 Hook:带过期时间的 localStorage 持久化状态
+ * @param {string} key - localStorage 的 key
+ * @param {*} initialValue - 初始值
+ * @param {number} expiryMs - 过期时间(毫秒),0 表示不过期
+ * @returns {[*, Function]} - [状态值, 设置函数]
*/
-const NotificationItem = ({ notification, onClose, isNewest = false }) => {
+const useLocalStorageWithExpiry = (key, initialValue, expiryMs = 0) => {
+ // 从 localStorage 读取带过期时间的值
+ const readValue = () => {
+ try {
+ const item = window.localStorage.getItem(key);
+ if (!item) {
+ return initialValue;
+ }
+
+ const { value, timestamp } = JSON.parse(item);
+
+ // 检查是否过期(仅当设置了过期时间时)
+ if (expiryMs > 0 && timestamp) {
+ const now = Date.now();
+ const elapsed = now - timestamp;
+
+ if (elapsed > expiryMs) {
+ // 已过期,删除并返回初始值
+ window.localStorage.removeItem(key);
+ return initialValue;
+ }
+ }
+
+ return value;
+ } catch (error) {
+ console.error(`Error reading ${key} from localStorage:`, error);
+ return initialValue;
+ }
+ };
+
+ const [storedValue, setStoredValue] = useState(readValue);
+
+ // 保存值到 localStorage(带时间戳)
+ const setValue = (value) => {
+ try {
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
+ setStoredValue(valueToStore);
+
+ const item = {
+ value: valueToStore,
+ timestamp: Date.now(), // 保存时间戳
+ };
+
+ window.localStorage.setItem(key, JSON.stringify(item));
+ } catch (error) {
+ console.error(`Error saving ${key} to localStorage:`, error);
+ }
+ };
+
+ // 监听 storage 事件(跨标签页同步)
+ useEffect(() => {
+ const handleStorageChange = (e) => {
+ if (e.key === key && e.newValue !== null) {
+ try {
+ const { value, timestamp } = JSON.parse(e.newValue);
+
+ // 检查是否过期
+ if (expiryMs > 0 && timestamp) {
+ const now = Date.now();
+ const elapsed = now - timestamp;
+
+ if (elapsed > expiryMs) {
+ // 过期,设置为初始值
+ setStoredValue(initialValue);
+ return;
+ }
+ }
+
+ // 更新状态
+ setStoredValue(value);
+ } catch (error) {
+ console.error(`Error parsing storage event for ${key}:`, error);
+ }
+ } else if (e.key === key && e.newValue === null) {
+ // 其他标签页删除了该值
+ setStoredValue(initialValue);
+ }
+ };
+
+ // 添加事件监听
+ window.addEventListener('storage', handleStorageChange);
+
+ // 清理函数
+ return () => {
+ window.removeEventListener('storage', handleStorageChange);
+ };
+ }, [key, expiryMs, initialValue]);
+
+ // 定时检查过期(可选,更精确的过期控制)
+ useEffect(() => {
+ if (expiryMs <= 0) return; // 不需要过期检查
+
+ const checkExpiry = () => {
+ try {
+ const item = window.localStorage.getItem(key);
+ if (!item) return;
+
+ const { value, timestamp } = JSON.parse(item);
+ const now = Date.now();
+ const elapsed = now - timestamp;
+
+ if (elapsed > expiryMs) {
+ // 已过期,重置状态
+ setStoredValue(initialValue);
+ window.localStorage.removeItem(key);
+ }
+ } catch (error) {
+ console.error(`Error checking expiry for ${key}:`, error);
+ }
+ };
+
+ // 每10秒检查一次过期
+ const intervalId = setInterval(checkExpiry, 10000);
+
+ // 立即执行一次检查
+ checkExpiry();
+
+ return () => clearInterval(intervalId);
+ }, [key, expiryMs, initialValue]);
+
+ return [storedValue, setValue];
+};
+
+/**
+ * 辅助函数:生成通知的完整无障碍描述
+ * @param {object} notification - 通知对象
+ * @returns {string} - ARIA 描述文本
+ */
+const getNotificationDescription = (notification) => {
+ const { type, priority, title, content, isAIGenerated, publishTime, pushTime, extra } = notification;
+
+ // 获取配置
+ const typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
+ const priorityConfig = PRIORITY_CONFIGS[priority];
+
+ // 构建描述片段
+ const parts = [];
+
+ // 优先级(如果需要显示)
+ if (priorityConfig?.show) {
+ parts.push(`${priorityConfig.label}通知`);
+ }
+
+ // 类型
+ parts.push(typeConfig.name);
+
+ // 标题
+ parts.push(title);
+
+ // 内容
+ if (content) {
+ parts.push(content);
+ }
+
+ // AI 生成标识
+ if (isAIGenerated) {
+ parts.push('由AI生成');
+ }
+
+ // 预测标识
+ if (extra?.isPrediction) {
+ parts.push('预测状态');
+ if (extra?.statusHint) {
+ parts.push(extra.statusHint);
+ }
+ }
+
+ // 时间信息
+ const time = publishTime || pushTime;
+ if (time) {
+ parts.push(`时间:${formatNotificationTime(time)}`);
+ }
+
+ // 操作提示
+ if (notification.clickable && notification.link) {
+ parts.push('按回车键或空格键查看详情');
+ }
+
+ return parts.join(',');
+};
+
+/**
+ * 辅助函数:处理键盘按键事件(Enter / Space)
+ * @param {KeyboardEvent} event - 键盘事件
+ * @param {Function} callback - 回调函数
+ */
+const handleKeyPress = (event, callback) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ callback();
+ }
+};
+
+/**
+ * 紧急通知脉冲动画 - 边框颜色脉冲效果
+ */
+const pulseAnimation = keyframes`
+ 0%, 100% {
+ border-left-color: currentColor;
+ box-shadow: 0 0 0 0 currentColor;
+ }
+ 50% {
+ border-left-color: currentColor;
+ box-shadow: -4px 0 12px 0 currentColor;
+ }
+`;
+
+/**
+ * 单个通知项组件
+ * 使用 React.memo 优化,避免不必要的重渲染
+ */
+const NotificationItem = React.memo(({ notification, onClose, isNewest = false }) => {
const navigate = useNavigate();
+ const { trackNotificationClick } = useNotification();
+
+ // 加载状态管理 - 点击跳转时显示 loading
+ const [isNavigating, setIsNavigating] = useState(false);
+
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
@@ -51,77 +275,177 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
...typeConfig,
icon: typeConfig.getIcon(priceChange),
colorScheme: typeConfig.getColorScheme(priceChange),
+ // 亮色模式
bg: typeConfig.getBg(priceChange),
borderColor: typeConfig.getBorderColor(priceChange),
iconColor: typeConfig.getIconColor(priceChange),
hoverBg: typeConfig.getHoverBg(priceChange),
+ // 暗色模式
+ darkBg: typeConfig.getDarkBg(priceChange),
+ darkBorderColor: typeConfig.getDarkBorderColor(priceChange),
+ darkIconColor: typeConfig.getDarkIconColor(priceChange),
+ darkHoverBg: typeConfig.getDarkHoverBg(priceChange),
};
}
// 获取优先级配置
const priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
- const bgColor = useColorModeValue(typeConfig.bg, `${typeConfig.colorScheme}.900`);
- const borderColor = useColorModeValue(typeConfig.borderColor, `${typeConfig.colorScheme}.500`);
- const textColor = useColorModeValue('gray.800', 'white');
- const subTextColor = useColorModeValue('gray.600', 'gray.300');
- const metaTextColor = useColorModeValue('gray.500', 'gray.400');
- const hoverBg = typeConfig.hoverBg;
- const closeButtonHoverBg = useColorModeValue(`${typeConfig.colorScheme}.200`, `${typeConfig.colorScheme}.700`);
+ // 判断是否显示图标(仅紧急和重要通知)
+ const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
- // 点击处理(只有真正可点击时才执行)
- const handleClick = () => {
- if (isActuallyClickable) {
- navigate(link);
+ // 获取优先级样式
+ const priorityBorderWidth = getPriorityBorderWidth(priority);
+ const isDark = useColorModeValue(false, true);
+ const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
+
+ // 根据优先级调整背景色深度
+ const getPriorityBgColor = () => {
+ const colorScheme = typeConfig.colorScheme;
+ // 亮色模式:根据优先级使用不同深度的颜色
+ if (!isDark) {
+ if (priority === PRIORITY_LEVELS.URGENT) {
+ return `${colorScheme}.200`; // 紧急:深色背景 + 脉冲动画
+ } else if (priority === PRIORITY_LEVELS.IMPORTANT) {
+ return `${colorScheme}.100`; // 重要:中色背景
+ } else {
+ // 普通:极淡背景(使用 white 或 gray.50,降低视觉干扰)
+ return 'white';
+ }
+ } else {
+ // 暗色模式:使用 typeConfig 的 darkBg 或回退
+ if (priority === PRIORITY_LEVELS.URGENT) {
+ return typeConfig.darkBg || `${colorScheme}.800`;
+ } else if (priority === PRIORITY_LEVELS.IMPORTANT) {
+ return typeConfig.darkBg || `${colorScheme}.800`;
+ } else {
+ // 普通通知在暗色模式下使用更暗的灰色背景
+ return 'gray.800';
+ }
}
};
+ // 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
+ const colors = useMemo(() => ({
+ bg: getPriorityBgColor(),
+ border: useColorModeValue(
+ typeConfig.borderColor,
+ typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
+ ),
+ icon: useColorModeValue(
+ typeConfig.iconColor,
+ typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
+ ),
+ text: useColorModeValue('gray.800', 'gray.100'),
+ subText: useColorModeValue('gray.600', 'gray.300'),
+ metaText: useColorModeValue('gray.500', 'gray.500'),
+ hoverBg: useColorModeValue(
+ typeConfig.hoverBg,
+ typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
+ ),
+ closeButtonHoverBg: useColorModeValue(
+ `${typeConfig.colorScheme}.200`,
+ `${typeConfig.colorScheme}.700`
+ ),
+ }), [isDark, priority, typeConfig]);
+
+ // 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
+ const handleClick = useCallback(() => {
+ if (isActuallyClickable && !isNavigating) {
+ // 设置加载状态
+ setIsNavigating(true);
+
+ // 追踪点击(监控埋点)
+ trackNotificationClick(id);
+
+ // 导航到目标页面
+ navigate(link);
+
+ // 延迟关闭通知(给用户足够的视觉反馈 - 300ms)
+ setTimeout(() => {
+ onClose(id, true);
+ }, 300);
+ }
+ }, [id, link, isActuallyClickable, isNavigating, trackNotificationClick, navigate, onClose]);
+
+ // 生成完整的无障碍描述
+ const ariaDescription = getNotificationDescription(notification);
+
return (
-
-
- {/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
+ isActuallyClickable && handleKeyPress(e, handleClick)}
+ // 样式属性
+ bg={colors.bg}
+ borderLeft={`${priorityBorderWidth} solid`}
+ borderColor={colors.border}
+ // 可点击的通知添加完整边框提示
+ {...(isActuallyClickable && {
+ border: '1px solid',
+ borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
+ })}
+ borderRadius="md"
+ // 可点击的通知使用更明显的阴影(悬浮感)
+ boxShadow={isActuallyClickable
+ ? (isNewest ? '2xl' : 'md')
+ : (isNewest ? 'xl' : 'sm')}
+ // 紧急通知添加脉冲动画
+ animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
+ p={{ base: 3, md: 4 }}
+ w={{ base: "calc(100vw - 32px)", sm: "360px", md: "380px", lg: "400px" }}
+ maxW="400px"
+ position="relative"
+ cursor={isActuallyClickable ? (isNavigating ? 'wait' : 'pointer') : 'default'}
+ onClick={isActuallyClickable && !isNavigating ? handleClick : undefined}
+ opacity={isNavigating ? 0.7 : 1}
+ pointerEvents={isNavigating ? 'none' : 'auto'}
+ _hover={isActuallyClickable && !isNavigating ? {
+ boxShadow: 'xl',
+ transform: 'translateY(-2px)',
+ bg: colors.hoverBg,
+ } : {}} // 不可点击时无 hover 效果
+ _focus={{
+ outline: '2px solid',
+ outlineColor: 'blue.500',
+ outlineOffset: '2px',
+ }}
+ transition="all 0.2s"
+ willChange="transform, opacity" // 性能优化:GPU 加速
+ {...(isNewest && {
+ borderRight: '1px solid',
+ borderRightColor: colors.border,
+ borderTop: '1px solid',
+ borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
+ })}
+ >
+ {/* 头部区域:标题 + 可选标识 */}
- {/* 类型图标 */}
-
+ {/* 类型图标 - 仅紧急和重要通知显示 */}
+ {shouldShowIcon && (
+
+ )}
{/* 标题 */}
{title}
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
)}
- {/* 预测标识 */}
- {isPrediction && (
-
- 预测
-
- )}
-
- {/* AI 生成标识 */}
- {isAIGenerated && (
-
- AI
-
- )}
-
{/* 关闭按钮 */}
}
size="xs"
variant="ghost"
colorScheme={typeConfig.colorScheme}
- aria-label="关闭通知"
+ aria-label={`关闭通知:${title}`}
onClick={(e) => {
e.stopPropagation();
onClose(id);
}}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ onClose(id);
+ }
+ }}
flexShrink={0}
_hover={{
- bg: closeButtonHoverBg,
+ bg: colors.closeButtonHoverBg,
}}
/>
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
{/* 内容区域 */}
{content}
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
- {/* 作者信息(仅分析报告) */}
- {author && (
-
- 👤
- {author.name} - {author.organization}
- |
-
- )}
-
{/* 时间信息 */}
- 📅
+
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
- {/* 状态提示(仅预测通知) */}
- {extra?.statusHint && (
+ {/* AI 标识 - 小徽章(小屏及以上显示)*/}
+ {isAIGenerated && (
<>
- |
-
-
- {extra.statusHint}
+ |
+
+ AI
+
+ >
+ )}
+
+ {/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
+ {isPrediction && (
+ <>
+ |
+
+ 预测
+ {extra?.statusHint && (
+ <>
+
+ {extra.statusHint}
+ >
+ )}
>
)}
- {/* 可点击提示(仅真正可点击的通知) */}
+ {/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
+ {author && (
+ <>
+ |
+
+
+ {author.name} - {author.organization}
+
+ >
+ )}
+
+ {/* 可点击提示(仅真正可点击的通知)*/}
{isActuallyClickable && (
<>
|
-
- 查看详情
+ {/* Loading 时显示 Spinner,否则显示图标 */}
+ {isNavigating ? (
+
+ ) : (
+
+ )}
+ {/* Loading 时显示"跳转中...",否则显示"查看详情" */}
+
+ {isNavigating ? '跳转中...' : '查看详情'}
+
>
)}
-
-
+
);
-};
+}, (prevProps, nextProps) => {
+ // 自定义比较函数:只在 id 或 isNewest 变化时重渲染
+ return (
+ prevProps.notification.id === nextProps.notification.id &&
+ prevProps.isNewest === nextProps.isNewest
+ );
+});
/**
* 通知容器组件 - 主组件
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
- const [isExpanded, setIsExpanded] = useState(false);
+ // 使用带过期时间的 localStorage(2分钟 = 120000 毫秒)
+ const [isExpanded, setIsExpanded] = useLocalStorageWithExpiry(
+ 'notification-expanded-state',
+ false,
+ 120000
+ );
+
+ // 追踪新通知(性能优化:只对新通知做动画)
+ const prevNotificationIdsRef = useRef(new Set());
+ const isFirstRenderRef = useRef(true);
+ const [newNotificationIds, setNewNotificationIds] = useState(new Set());
+
+ useEffect(() => {
+ // 首次渲染跳过动画检测
+ if (isFirstRenderRef.current) {
+ isFirstRenderRef.current = false;
+ const currentIds = new Set(notifications.map(n => n.id));
+ prevNotificationIdsRef.current = currentIds;
+ return;
+ }
+
+ const currentIds = new Set(notifications.map(n => n.id));
+ const prevIds = prevNotificationIdsRef.current;
+
+ // 找出新增的通知 ID
+ const newIds = new Set();
+ currentIds.forEach(id => {
+ if (!prevIds.has(id)) {
+ newIds.add(id);
+ }
+ });
+
+ setNewNotificationIds(newIds);
+
+ // 更新引用
+ prevNotificationIdsRef.current = currentIds;
+
+ // 1秒后清除新通知标记(动画完成)
+ if (newIds.size > 0) {
+ const timer = setTimeout(() => {
+ setNewNotificationIds(new Set());
+ }, 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [notifications]);
// 如果没有通知,不渲染
if (notifications.length === 0) {
@@ -265,11 +652,18 @@ const NotificationContainer = () => {
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
+ // 构建无障碍描述
+ const containerAriaLabel = hasMore
+ ? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航,Enter键或空格键查看详情。`
+ : `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航,Enter键或空格键查看详情。`;
+
return (
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
align="flex-end"
pointerEvents="auto"
>
- {visibleNotifications.map((notification, index) => (
-
-
-
- ))}
+
+ {visibleNotifications.map((notification, index) => (
+
+
+
+ ))}
+
{/* 折叠/展开按钮 */}
{hasMore && (
-
+
}
onClick={() => setIsExpanded(!isExpanded)}
+ aria-expanded={isExpanded}
+ aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
boxShadow="md"
borderRadius="md"
>
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
-
+
)}
diff --git a/src/constants/notificationTypes.js b/src/constants/notificationTypes.js
index 6dde81fc..8c67c099 100644
--- a/src/constants/notificationTypes.js
+++ b/src/constants/notificationTypes.js
@@ -58,30 +58,66 @@ export const PRIORITY_CONFIGS = {
[PRIORITY_LEVELS.URGENT]: {
label: '紧急',
colorScheme: 'red',
- show: true,
+ show: false, // 不再显示标签,改用边框+背景色表示
+ borderWidth: '6px', // 紧急:粗边框
+ bgOpacity: 0.25, // 紧急:深色背景
+ darkBgOpacity: 0.30, // 暗色模式下更明显
},
[PRIORITY_LEVELS.IMPORTANT]: {
label: '重要',
colorScheme: 'orange',
- show: true,
+ show: false, // 不再显示标签,改用边框+背景色表示
+ borderWidth: '4px', // 重要:中等边框
+ bgOpacity: 0.15, // 重要:中色背景
+ darkBgOpacity: 0.20, // 暗色模式
},
[PRIORITY_LEVELS.NORMAL]: {
label: '',
colorScheme: 'gray',
show: false, // 普通优先级不显示标签
+ borderWidth: '2px', // 普通:细边框
+ bgOpacity: 0.08, // 普通:浅色背景
+ darkBgOpacity: 0.12, // 暗色模式
},
};
+/**
+ * 根据优先级获取背景色透明度
+ * @param {string} priority - 优先级
+ * @param {boolean} isDark - 是否暗色模式
+ * @returns {number} - 透明度值 (0-1)
+ */
+export const getPriorityBgOpacity = (priority, isDark = false) => {
+ const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
+ return isDark ? config.darkBgOpacity : config.bgOpacity;
+};
+
+/**
+ * 根据优先级获取边框宽度
+ * @param {string} priority - 优先级
+ * @returns {string} - 边框宽度
+ */
+export const getPriorityBorderWidth = (priority) => {
+ const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
+ return config.borderWidth;
+};
+
// 通知类型样式配置
export const NOTIFICATION_TYPE_CONFIGS = {
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
name: '公告通知',
icon: MdCampaign,
colorScheme: 'blue',
+ // 亮色模式
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
hoverBg: 'blue.100',
+ // 暗色模式
+ darkBg: 'rgba(59, 130, 246, 0.15)', // blue.500 + 15% 透明度
+ darkBorderColor: 'blue.400',
+ darkIconColor: 'blue.300',
+ darkHoverBg: 'rgba(59, 130, 246, 0.25)', // Hover 时 25% 透明度
},
[NOTIFICATION_TYPES.STOCK_ALERT]: {
name: '股票动向',
@@ -95,6 +131,7 @@ export const NOTIFICATION_TYPE_CONFIGS = {
if (!priceChange) return 'red';
return priceChange.startsWith('+') ? 'red' : 'green';
},
+ // 亮色模式
getBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.50`;
@@ -111,24 +148,58 @@ export const NOTIFICATION_TYPE_CONFIGS = {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.100`;
},
+ // 暗色模式
+ getDarkBg: (priceChange) => {
+ const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
+ // red (上涨): rgba(239, 68, 68, 0.15), green (下跌): rgba(34, 197, 94, 0.15)
+ return scheme === 'red'
+ ? 'rgba(239, 68, 68, 0.15)'
+ : 'rgba(34, 197, 94, 0.15)';
+ },
+ getDarkBorderColor: (priceChange) => {
+ const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
+ return `${scheme}.400`;
+ },
+ getDarkIconColor: (priceChange) => {
+ const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
+ return `${scheme}.300`;
+ },
+ getDarkHoverBg: (priceChange) => {
+ const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
+ return scheme === 'red'
+ ? 'rgba(239, 68, 68, 0.25)'
+ : 'rgba(34, 197, 94, 0.25)';
+ },
},
[NOTIFICATION_TYPES.EVENT_ALERT]: {
name: '事件动向',
icon: MdArticle,
colorScheme: 'orange',
+ // 亮色模式
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
hoverBg: 'orange.100',
+ // 暗色模式
+ darkBg: 'rgba(249, 115, 22, 0.15)', // orange.500 + 15% 透明度
+ darkBorderColor: 'orange.400',
+ darkIconColor: 'orange.300',
+ darkHoverBg: 'rgba(249, 115, 22, 0.25)',
},
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
name: '分析报告',
icon: MdAssessment,
colorScheme: 'purple',
+ // 亮色模式
bg: 'purple.50',
borderColor: 'purple.400',
iconColor: 'purple.500',
hoverBg: 'purple.100',
+ // 暗色模式
+ darkBg: 'rgba(168, 85, 247, 0.15)', // purple.500 + 15% 透明度
+ darkBorderColor: 'purple.400',
+ darkIconColor: 'purple.300',
+ darkHoverBg: 'rgba(168, 85, 247, 0.25)',
},
};
@@ -178,4 +249,6 @@ export default {
PRIORITY_CONFIGS,
NOTIFICATION_TYPE_CONFIGS,
formatNotificationTime,
+ getPriorityBgOpacity,
+ getPriorityBorderWidth,
};
diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js
index 64bdfe78..91ee8d40 100644
--- a/src/contexts/NotificationContext.js
+++ b/src/contexts/NotificationContext.js
@@ -4,11 +4,22 @@
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
+import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
import { browserNotificationService } from '../services/browserNotificationService';
-import { PRIORITY_LEVELS, NOTIFICATION_CONFIG } from '../constants/notificationTypes';
+import { notificationMetricsService } from '../services/notificationMetricsService';
+import { notificationHistoryService } from '../services/notificationHistoryService';
+import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
+
+// 连接状态枚举
+const CONNECTION_STATUS = {
+ CONNECTED: 'connected',
+ DISCONNECTED: 'disconnected',
+ RECONNECTING: 'reconnecting',
+ FAILED: 'failed',
+};
// 创建通知上下文
const NotificationContext = createContext();
@@ -24,10 +35,17 @@ export const useNotification = () => {
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
+ const toast = useToast();
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true);
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
+ const [hasRequestedPermission, setHasRequestedPermission] = useState(() => {
+ // 从 localStorage 读取是否已请求过权限
+ return localStorage.getItem('browser_notification_requested') === 'true';
+ });
+ const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.CONNECTED);
+ const [reconnectAttempt, setReconnectAttempt] = useState(0);
const audioRef = useRef(null);
// 初始化音频
@@ -63,10 +81,19 @@ export const NotificationProvider = ({ children }) => {
/**
* 移除通知
* @param {string} id - 通知ID
+ * @param {boolean} wasClicked - 是否是因为点击而关闭
*/
- const removeNotification = useCallback((id) => {
- logger.info('NotificationContext', 'Removing notification', { id });
- setNotifications(prev => prev.filter(notif => notif.id !== id));
+ const removeNotification = useCallback((id, wasClicked = false) => {
+ logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
+
+ // 监控埋点:追踪关闭(非点击的情况)
+ setNotifications(prev => {
+ const notification = prev.find(n => n.id === id);
+ if (notification && !wasClicked) {
+ notificationMetricsService.trackDismissed(notification);
+ }
+ return prev.filter(notif => notif.id !== id);
+ });
}, []);
/**
@@ -95,8 +122,32 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Requesting browser notification permission');
const permission = await browserNotificationService.requestPermission();
setBrowserPermission(permission);
+
+ // 记录已请求过权限
+ setHasRequestedPermission(true);
+ localStorage.setItem('browser_notification_requested', 'true');
+
+ // 根据权限结果显示 Toast 提示
+ if (permission === 'granted') {
+ toast({
+ title: '桌面通知已开启',
+ description: '您现在可以在后台接收重要通知',
+ status: 'success',
+ duration: 3000,
+ isClosable: true,
+ });
+ } else if (permission === 'denied') {
+ toast({
+ title: '桌面通知已关闭',
+ description: '您将继续在网页内收到通知',
+ status: 'info',
+ duration: 5000,
+ isClosable: true,
+ });
+ }
+
return permission;
- }, []);
+ }, [toast]);
/**
* 发送浏览器通知
@@ -138,10 +189,82 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
}, [browserPermission]);
+ /**
+ * 事件数据适配器 - 将后端事件格式转换为前端通知格式
+ * @param {object} event - 后端事件对象
+ * @returns {object} - 前端通知对象
+ */
+ const adaptEventToNotification = useCallback((event) => {
+ // 检测数据格式:如果已经是前端格式(包含 priority),直接返回
+ if (event.priority || event.type === NOTIFICATION_TYPES.ANNOUNCEMENT || event.type === NOTIFICATION_TYPES.STOCK_ALERT) {
+ logger.debug('NotificationContext', 'Event is already in notification format', { id: event.id });
+ return event;
+ }
+
+ // 转换后端事件格式到前端通知格式
+ logger.debug('NotificationContext', 'Converting backend event to notification format', {
+ eventId: event.id,
+ eventType: event.event_type,
+ importance: event.importance
+ });
+
+ // 重要性映射:S/A → urgent/important, B/C → normal
+ let priority = PRIORITY_LEVELS.NORMAL;
+ if (event.importance === 'S') {
+ priority = PRIORITY_LEVELS.URGENT;
+ } else if (event.importance === 'A') {
+ priority = PRIORITY_LEVELS.IMPORTANT;
+ }
+
+ // 获取自动关闭时长
+ const autoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
+
+ // 构建通知对象
+ const notification = {
+ id: event.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type: NOTIFICATION_TYPES.EVENT_ALERT, // 统一使用"事件动向"类型
+ priority: priority,
+ title: event.title || '新事件',
+ content: event.description || event.content || '',
+ publishTime: event.created_at ? new Date(event.created_at).getTime() : Date.now(),
+ pushTime: Date.now(),
+ timestamp: Date.now(),
+ isAIGenerated: event.is_ai_generated || false,
+ clickable: true,
+ link: `/event-detail/${event.id}`,
+ autoClose: autoClose,
+ extra: {
+ eventId: event.id,
+ eventType: event.event_type,
+ importance: event.importance,
+ status: event.status,
+ hotScore: event.hot_score,
+ viewCount: event.view_count,
+ relatedAvgChg: event.related_avg_chg,
+ relatedMaxChg: event.related_max_chg,
+ keywords: event.keywords || [],
+ },
+ };
+
+ logger.info('NotificationContext', 'Event converted to notification', {
+ eventId: event.id,
+ notificationId: notification.id,
+ priority: notification.priority,
+ });
+
+ return notification;
+ }, []);
+
/**
* 添加网页通知(内部方法)
*/
const addWebNotification = useCallback((newNotification) => {
+ // 监控埋点:追踪通知接收
+ notificationMetricsService.trackReceived(newNotification);
+
+ // 保存到历史记录
+ notificationHistoryService.saveNotification(newNotification);
+
// 新消息插入到数组开头,最多保留 maxHistory 条
setNotifications(prev => {
const updated = [newNotification, ...prev];
@@ -174,7 +297,7 @@ export const NotificationProvider = ({ children }) => {
* 添加通知到队列
* @param {object} notification - 通知对象
*/
- const addNotification = useCallback((notification) => {
+ const addNotification = useCallback(async (notification) => {
// 根据优先级获取自动关闭时长
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
@@ -193,6 +316,62 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Adding notification', newNotification);
+ // ========== 智能权限请求策略 ==========
+ // 首次收到重要/紧急通知时,自动请求桌面通知权限
+ if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) {
+ if (browserPermission === 'default' && !hasRequestedPermission) {
+ logger.info('NotificationContext', 'First important notification, requesting browser permission');
+ await requestBrowserPermission();
+ }
+ // 如果权限被拒绝,提示用户可以开启
+ else if (browserPermission === 'denied' && hasRequestedPermission) {
+ // 显示带"开启"按钮的 Toast(仅重要/紧急通知)
+ const toastId = 'enable-notification-toast';
+ if (!toast.isActive(toastId)) {
+ toast({
+ id: toastId,
+ title: newNotification.title,
+ description: '💡 开启桌面通知以便后台接收',
+ status: 'warning',
+ duration: 10000,
+ isClosable: true,
+ position: 'top',
+ render: ({ onClose }) => (
+
+
+
+
+ {newNotification.title}
+
+
+ 💡 开启桌面通知以便后台接收
+
+
+
+
+
+
+ ),
+ });
+ }
+ }
+ }
+
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 智能分发策略 ==========
@@ -224,7 +403,7 @@ export const NotificationProvider = ({ children }) => {
}
return newNotification.id;
- }, [sendBrowserNotification, addWebNotification]);
+ }, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
useEffect(() => {
@@ -236,8 +415,20 @@ export const NotificationProvider = ({ children }) => {
// 监听连接状态
socket.on('connect', () => {
setIsConnected(true);
+ setConnectionStatus(CONNECTION_STATUS.CONNECTED);
+ setReconnectAttempt(0);
logger.info('NotificationContext', 'Socket connected');
+ // 显示重连成功提示(如果之前断开过)
+ if (connectionStatus !== CONNECTION_STATUS.CONNECTED) {
+ toast({
+ title: '已重新连接',
+ status: 'success',
+ duration: 2000,
+ isClosable: true,
+ });
+ }
+
// 如果使用 mock,可以启动定期推送
if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送:使用配置的间隔和数量
@@ -247,18 +438,48 @@ export const NotificationProvider = ({ children }) => {
}
});
- socket.on('disconnect', () => {
+ socket.on('disconnect', (reason) => {
setIsConnected(false);
- logger.warn('NotificationContext', 'Socket disconnected');
+ setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
+ logger.warn('NotificationContext', 'Socket disconnected', { reason });
});
- // 监听交易通知
- socket.on('trade_notification', (data) => {
- logger.info('NotificationContext', 'Received trade notification', data);
- addNotification(data);
+ // 监听连接错误
+ socket.on('connect_error', (error) => {
+ logger.error('NotificationContext', 'Socket connect_error', error);
+ setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
+
+ // 获取重连次数(仅 Real Socket 有)
+ if (SOCKET_TYPE === 'REAL') {
+ const attempts = socket.getReconnectAttempts?.() || 0;
+ setReconnectAttempt(attempts);
+ }
});
- // 监听系统通知
+ // 监听重连失败
+ socket.on('reconnect_failed', () => {
+ logger.error('NotificationContext', 'Socket reconnect_failed');
+ setConnectionStatus(CONNECTION_STATUS.FAILED);
+
+ toast({
+ title: '连接失败',
+ description: '无法连接到服务器,请检查网络连接',
+ status: 'error',
+ duration: null, // 不自动关闭
+ isClosable: true,
+ });
+ });
+
+ // 监听新事件推送(统一事件名)
+ socket.on('new_event', (data) => {
+ logger.info('NotificationContext', 'Received new event', data);
+
+ // 使用适配器转换事件格式
+ const notification = adaptEventToNotification(data);
+ addNotification(notification);
+ });
+
+ // 保留系统通知监听(兼容性)
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
addNotification(data);
@@ -275,22 +496,111 @@ export const NotificationProvider = ({ children }) => {
socket.off('connect');
socket.off('disconnect');
- socket.off('trade_notification');
+ socket.off('connect_error');
+ socket.off('reconnect_failed');
+ socket.off('new_event');
socket.off('system_notification');
socket.disconnect();
};
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ==================== 智能自动重试 ====================
+
+ /**
+ * 标签页聚焦时自动重试
+ */
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
+ logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
+ if (SOCKET_TYPE === 'REAL') {
+ socket.reconnect?.();
+ } else {
+ socket.connect();
+ }
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, [isConnected, connectionStatus]);
+
+ /**
+ * 网络恢复时自动重试
+ */
+ useEffect(() => {
+ const handleOnline = () => {
+ if (!isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
+ logger.info('NotificationContext', 'Network restored, attempting auto-reconnect');
+ toast({
+ title: '网络已恢复',
+ description: '正在重新连接...',
+ status: 'info',
+ duration: 2000,
+ isClosable: true,
+ });
+
+ if (SOCKET_TYPE === 'REAL') {
+ socket.reconnect?.();
+ } else {
+ socket.connect();
+ }
+ }
+ };
+
+ window.addEventListener('online', handleOnline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ };
+ }, [isConnected, connectionStatus, toast]);
+
+ /**
+ * 追踪通知点击
+ * @param {string} id - 通知ID
+ */
+ const trackNotificationClick = useCallback((id) => {
+ const notification = notifications.find(n => n.id === id);
+ if (notification) {
+ logger.info('NotificationContext', 'Notification clicked', { id });
+ // 监控埋点:追踪点击
+ notificationMetricsService.trackClicked(notification);
+ // 标记历史记录为已点击
+ notificationHistoryService.markAsClicked(id);
+ }
+ }, [notifications]);
+
+ /**
+ * 手动重试连接
+ */
+ const retryConnection = useCallback(() => {
+ logger.info('NotificationContext', 'Manual reconnection triggered');
+ setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
+
+ if (SOCKET_TYPE === 'REAL') {
+ socket.reconnect?.();
+ } else {
+ socket.connect();
+ }
+ }, []);
const value = {
notifications,
isConnected,
soundEnabled,
browserPermission,
+ connectionStatus,
+ reconnectAttempt,
addNotification,
removeNotification,
clearAllNotifications,
toggleSound,
requestBrowserPermission,
+ trackNotificationClick,
+ retryConnection,
};
return (
@@ -300,4 +610,7 @@ export const NotificationProvider = ({ children }) => {
);
};
+// 导出连接状态枚举供外部使用
+export { CONNECTION_STATUS };
+
export default NotificationContext;
diff --git a/src/services/mockSocketService.js b/src/services/mockSocketService.js
index ce2d26bc..e3897d76 100644
--- a/src/services/mockSocketService.js
+++ b/src/services/mockSocketService.js
@@ -329,7 +329,7 @@ class MockSocketService {
// 在连接后3秒发送欢迎消息
setTimeout(() => {
- this.emit('trade_notification', {
+ this.emit('new_event', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
@@ -445,7 +445,7 @@ class MockSocketService {
// 延迟发送(模拟层叠效果)
setTimeout(() => {
- this.emit('trade_notification', alert);
+ this.emit('new_event', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
@@ -478,7 +478,7 @@ class MockSocketService {
id: `test_${Date.now()}`,
};
- this.emit('trade_notification', notification);
+ this.emit('new_event', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}
diff --git a/src/services/notificationHistoryService.js b/src/services/notificationHistoryService.js
new file mode 100644
index 00000000..357c6eb1
--- /dev/null
+++ b/src/services/notificationHistoryService.js
@@ -0,0 +1,402 @@
+// src/services/notificationHistoryService.js
+/**
+ * 通知历史记录服务
+ * 持久化存储通知历史,支持查询、筛选、搜索、导出
+ */
+
+import { logger } from '../utils/logger';
+
+const STORAGE_KEY = 'notification_history';
+const MAX_HISTORY_SIZE = 500; // 最多保留 500 条历史记录
+
+class NotificationHistoryService {
+ constructor() {
+ this.history = this.loadHistory();
+ }
+
+ /**
+ * 从 localStorage 加载历史记录
+ */
+ loadHistory() {
+ try {
+ const data = localStorage.getItem(STORAGE_KEY);
+ if (data) {
+ const parsed = JSON.parse(data);
+ // 确保是数组
+ return Array.isArray(parsed) ? parsed : [];
+ }
+ } catch (error) {
+ logger.error('notificationHistoryService', 'loadHistory', error);
+ }
+ return [];
+ }
+
+ /**
+ * 保存历史记录到 localStorage
+ */
+ saveHistory() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
+ logger.debug('notificationHistoryService', 'History saved', {
+ count: this.history.length
+ });
+ } catch (error) {
+ logger.error('notificationHistoryService', 'saveHistory', error);
+ // localStorage 可能已满,尝试清理旧数据
+ if (error.name === 'QuotaExceededError') {
+ this.cleanup(100); // 清理 100 条
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
+ } catch (retryError) {
+ logger.error('notificationHistoryService', 'saveHistory retry failed', retryError);
+ }
+ }
+ }
+ }
+
+ /**
+ * 保存通知到历史记录
+ * @param {object} notification - 通知对象
+ */
+ saveNotification(notification) {
+ const record = {
+ id: notification.id || `notif_${Date.now()}`,
+ notification: { ...notification }, // 深拷贝
+ receivedAt: Date.now(),
+ readAt: null,
+ clickedAt: null,
+ };
+
+ // 检查是否已存在(去重)
+ const existingIndex = this.history.findIndex(r => r.id === record.id);
+ if (existingIndex !== -1) {
+ logger.debug('notificationHistoryService', 'Notification already exists', { id: record.id });
+ return;
+ }
+
+ // 添加到历史记录开头
+ this.history.unshift(record);
+
+ // 限制最大数量
+ if (this.history.length > MAX_HISTORY_SIZE) {
+ this.history = this.history.slice(0, MAX_HISTORY_SIZE);
+ }
+
+ this.saveHistory();
+
+ logger.info('notificationHistoryService', 'Notification saved', { id: record.id });
+ }
+
+ /**
+ * 标记通知为已读
+ * @param {string} id - 通知 ID
+ */
+ markAsRead(id) {
+ const record = this.history.find(r => r.id === id);
+ if (record && !record.readAt) {
+ record.readAt = Date.now();
+ this.saveHistory();
+ logger.debug('notificationHistoryService', 'Marked as read', { id });
+ }
+ }
+
+ /**
+ * 标记通知为已点击
+ * @param {string} id - 通知 ID
+ */
+ markAsClicked(id) {
+ const record = this.history.find(r => r.id === id);
+ if (record && !record.clickedAt) {
+ record.clickedAt = Date.now();
+ // 点击也意味着已读
+ if (!record.readAt) {
+ record.readAt = Date.now();
+ }
+ this.saveHistory();
+ logger.debug('notificationHistoryService', 'Marked as clicked', { id });
+ }
+ }
+
+ /**
+ * 获取历史记录
+ * @param {object} filters - 筛选条件
+ * @param {string} filters.type - 通知类型
+ * @param {string} filters.priority - 优先级
+ * @param {string} filters.readStatus - 阅读状态 ('read' | 'unread' | 'all')
+ * @param {number} filters.startDate - 开始日期(时间戳)
+ * @param {number} filters.endDate - 结束日期(时间戳)
+ * @param {number} filters.page - 页码(从 1 开始)
+ * @param {number} filters.pageSize - 每页数量
+ * @returns {object} - { records, total, page, pageSize }
+ */
+ getHistory(filters = {}) {
+ let filtered = [...this.history];
+
+ // 按类型筛选
+ if (filters.type && filters.type !== 'all') {
+ filtered = filtered.filter(r => r.notification.type === filters.type);
+ }
+
+ // 按优先级筛选
+ if (filters.priority && filters.priority !== 'all') {
+ filtered = filtered.filter(r => r.notification.priority === filters.priority);
+ }
+
+ // 按阅读状态筛选
+ if (filters.readStatus === 'read') {
+ filtered = filtered.filter(r => r.readAt !== null);
+ } else if (filters.readStatus === 'unread') {
+ filtered = filtered.filter(r => r.readAt === null);
+ }
+
+ // 按日期范围筛选
+ if (filters.startDate) {
+ filtered = filtered.filter(r => r.receivedAt >= filters.startDate);
+ }
+ if (filters.endDate) {
+ filtered = filtered.filter(r => r.receivedAt <= filters.endDate);
+ }
+
+ // 分页
+ const page = filters.page || 1;
+ const pageSize = filters.pageSize || 20;
+ const total = filtered.length;
+ const startIndex = (page - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ const records = filtered.slice(startIndex, endIndex);
+
+ return {
+ records,
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ };
+ }
+
+ /**
+ * 搜索历史记录
+ * @param {string} keyword - 搜索关键词
+ * @returns {array} - 匹配的记录
+ */
+ searchHistory(keyword) {
+ if (!keyword || keyword.trim() === '') {
+ return [];
+ }
+
+ const lowerKeyword = keyword.toLowerCase();
+
+ const results = this.history.filter(record => {
+ const { title, content, message } = record.notification;
+ const searchText = `${title || ''} ${content || ''} ${message || ''}`.toLowerCase();
+ return searchText.includes(lowerKeyword);
+ });
+
+ logger.info('notificationHistoryService', 'Search completed', {
+ keyword,
+ resultsCount: results.length
+ });
+
+ return results;
+ }
+
+ /**
+ * 删除单条历史记录
+ * @param {string} id - 通知 ID
+ */
+ deleteRecord(id) {
+ const initialLength = this.history.length;
+ this.history = this.history.filter(r => r.id !== id);
+
+ if (this.history.length < initialLength) {
+ this.saveHistory();
+ logger.info('notificationHistoryService', 'Record deleted', { id });
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * 批量删除历史记录
+ * @param {array} ids - 通知 ID 数组
+ */
+ deleteRecords(ids) {
+ const initialLength = this.history.length;
+ this.history = this.history.filter(r => !ids.includes(r.id));
+
+ const deletedCount = initialLength - this.history.length;
+
+ if (deletedCount > 0) {
+ this.saveHistory();
+ logger.info('notificationHistoryService', 'Batch delete completed', {
+ deletedCount
+ });
+ }
+
+ return deletedCount;
+ }
+
+ /**
+ * 清空所有历史记录
+ */
+ clearHistory() {
+ this.history = [];
+ this.saveHistory();
+ logger.info('notificationHistoryService', 'History cleared');
+ }
+
+ /**
+ * 清理旧数据
+ * @param {number} count - 要清理的数量
+ */
+ cleanup(count) {
+ if (this.history.length > count) {
+ this.history = this.history.slice(0, -count);
+ this.saveHistory();
+ logger.info('notificationHistoryService', 'Cleanup completed', { count });
+ }
+ }
+
+ /**
+ * 获取统计数据
+ * @returns {object} - 统计信息
+ */
+ getStats() {
+ const total = this.history.length;
+ const read = this.history.filter(r => r.readAt !== null).length;
+ const unread = total - read;
+ const clicked = this.history.filter(r => r.clickedAt !== null).length;
+
+ // 按类型统计
+ const byType = {};
+ const byPriority = {};
+
+ this.history.forEach(record => {
+ const { type, priority } = record.notification;
+
+ // 类型统计
+ if (type) {
+ byType[type] = (byType[type] || 0) + 1;
+ }
+
+ // 优先级统计
+ if (priority) {
+ byPriority[priority] = (byPriority[priority] || 0) + 1;
+ }
+ });
+
+ // 计算点击率
+ const clickRate = total > 0 ? ((clicked / total) * 100).toFixed(2) : '0.00';
+
+ return {
+ total,
+ read,
+ unread,
+ clicked,
+ clickRate,
+ byType,
+ byPriority,
+ };
+ }
+
+ /**
+ * 导出历史记录为 JSON
+ * @param {object} filters - 筛选条件(可选)
+ */
+ exportToJSON(filters = {}) {
+ const { records } = this.getHistory(filters);
+
+ const exportData = {
+ exportedAt: new Date().toISOString(),
+ total: records.length,
+ records: records.map(r => ({
+ id: r.id,
+ notification: r.notification,
+ receivedAt: new Date(r.receivedAt).toISOString(),
+ readAt: r.readAt ? new Date(r.readAt).toISOString() : null,
+ clickedAt: r.clickedAt ? new Date(r.clickedAt).toISOString() : null,
+ })),
+ };
+
+ return JSON.stringify(exportData, null, 2);
+ }
+
+ /**
+ * 导出历史记录为 CSV
+ * @param {object} filters - 筛选条件(可选)
+ */
+ exportToCSV(filters = {}) {
+ const { records } = this.getHistory(filters);
+
+ let csv = 'ID,Type,Priority,Title,Content,Received At,Read At,Clicked At\n';
+
+ records.forEach(record => {
+ const { id, notification, receivedAt, readAt, clickedAt } = record;
+ const { type, priority, title, content, message } = notification;
+
+ const escapeCsv = (str) => {
+ if (!str) return '';
+ return `"${String(str).replace(/"/g, '""')}"`;
+ };
+
+ csv += [
+ escapeCsv(id),
+ escapeCsv(type),
+ escapeCsv(priority),
+ escapeCsv(title),
+ escapeCsv(content || message),
+ new Date(receivedAt).toISOString(),
+ readAt ? new Date(readAt).toISOString() : '',
+ clickedAt ? new Date(clickedAt).toISOString() : '',
+ ].join(',') + '\n';
+ });
+
+ return csv;
+ }
+
+ /**
+ * 触发下载文件
+ * @param {string} content - 文件内容
+ * @param {string} filename - 文件名
+ * @param {string} mimeType - MIME 类型
+ */
+ downloadFile(content, filename, mimeType) {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ logger.info('notificationHistoryService', 'File downloaded', { filename });
+ }
+
+ /**
+ * 导出并下载 JSON 文件
+ * @param {object} filters - 筛选条件(可选)
+ */
+ downloadJSON(filters = {}) {
+ const json = this.exportToJSON(filters);
+ const filename = `notifications_${new Date().toISOString().split('T')[0]}.json`;
+ this.downloadFile(json, filename, 'application/json');
+ }
+
+ /**
+ * 导出并下载 CSV 文件
+ * @param {object} filters - 筛选条件(可选)
+ */
+ downloadCSV(filters = {}) {
+ const csv = this.exportToCSV(filters);
+ const filename = `notifications_${new Date().toISOString().split('T')[0]}.csv`;
+ this.downloadFile(csv, filename, 'text/csv');
+ }
+}
+
+// 导出单例
+export const notificationHistoryService = new NotificationHistoryService();
+
+export default notificationHistoryService;
diff --git a/src/services/notificationMetricsService.js b/src/services/notificationMetricsService.js
new file mode 100644
index 00000000..df623d46
--- /dev/null
+++ b/src/services/notificationMetricsService.js
@@ -0,0 +1,450 @@
+// src/services/notificationMetricsService.js
+/**
+ * 通知性能监控服务
+ * 追踪推送到达率、点击率、响应时间等指标
+ */
+
+import { logger } from '../utils/logger';
+
+const STORAGE_KEY = 'notification_metrics';
+const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
+
+class NotificationMetricsService {
+ constructor() {
+ this.metrics = this.loadMetrics();
+ }
+
+ /**
+ * 从 localStorage 加载指标数据
+ */
+ loadMetrics() {
+ try {
+ const data = localStorage.getItem(STORAGE_KEY);
+ if (data) {
+ const parsed = JSON.parse(data);
+ // 清理过期数据
+ this.cleanOldData(parsed);
+ return parsed;
+ }
+ } catch (error) {
+ logger.error('notificationMetricsService', 'loadMetrics', error);
+ }
+
+ // 返回默认结构
+ return this.getDefaultMetrics();
+ }
+
+ /**
+ * 获取默认指标结构
+ */
+ getDefaultMetrics() {
+ return {
+ summary: {
+ totalSent: 0,
+ totalReceived: 0,
+ totalClicked: 0,
+ totalDismissed: 0,
+ totalResponseTime: 0, // 总响应时间(毫秒)
+ },
+ byType: {
+ announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ },
+ byPriority: {
+ urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
+ },
+ hourlyDistribution: Array(24).fill(0), // 每小时推送分布
+ dailyData: {}, // 按日期存储的每日数据
+ lastUpdated: Date.now(),
+ };
+ }
+
+ /**
+ * 保存指标数据到 localStorage
+ */
+ saveMetrics() {
+ try {
+ this.metrics.lastUpdated = Date.now();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
+ logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
+ } catch (error) {
+ logger.error('notificationMetricsService', 'saveMetrics', error);
+ }
+ }
+
+ /**
+ * 清理过期数据
+ */
+ cleanOldData(metrics) {
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
+ const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
+
+ if (metrics.dailyData) {
+ Object.keys(metrics.dailyData).forEach(date => {
+ if (date < cutoffDateStr) {
+ delete metrics.dailyData[date];
+ }
+ });
+ }
+ }
+
+ /**
+ * 获取当前日期字符串
+ */
+ getTodayDateStr() {
+ return new Date().toISOString().split('T')[0];
+ }
+
+ /**
+ * 获取当前小时
+ */
+ getCurrentHour() {
+ return new Date().getHours();
+ }
+
+ /**
+ * 确保每日数据结构存在
+ */
+ ensureDailyData(dateStr) {
+ if (!this.metrics.dailyData[dateStr]) {
+ this.metrics.dailyData[dateStr] = {
+ sent: 0,
+ received: 0,
+ clicked: 0,
+ dismissed: 0,
+ };
+ }
+ }
+
+ /**
+ * 追踪通知发送
+ * @param {object} notification - 通知对象
+ */
+ trackSent(notification) {
+ const { type, priority } = notification;
+ const today = this.getTodayDateStr();
+ const hour = this.getCurrentHour();
+
+ // 汇总统计
+ this.metrics.summary.totalSent++;
+
+ // 按类型统计
+ if (this.metrics.byType[type]) {
+ this.metrics.byType[type].sent++;
+ }
+
+ // 按优先级统计
+ if (priority && this.metrics.byPriority[priority]) {
+ this.metrics.byPriority[priority].sent++;
+ }
+
+ // 每小时分布
+ this.metrics.hourlyDistribution[hour]++;
+
+ // 每日数据
+ this.ensureDailyData(today);
+ this.metrics.dailyData[today].sent++;
+
+ this.saveMetrics();
+
+ logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
+ }
+
+ /**
+ * 追踪通知接收
+ * @param {object} notification - 通知对象
+ */
+ trackReceived(notification) {
+ const { type, priority, id, timestamp } = notification;
+ const today = this.getTodayDateStr();
+
+ // 汇总统计
+ this.metrics.summary.totalReceived++;
+
+ // 按类型统计
+ if (this.metrics.byType[type]) {
+ this.metrics.byType[type].received++;
+ }
+
+ // 按优先级统计
+ if (priority && this.metrics.byPriority[priority]) {
+ this.metrics.byPriority[priority].received++;
+ }
+
+ // 每日数据
+ this.ensureDailyData(today);
+ this.metrics.dailyData[today].received++;
+
+ // 存储接收时间(用于计算响应时间)
+ this.storeReceivedTime(id, timestamp || Date.now());
+
+ this.saveMetrics();
+
+ logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
+ }
+
+ /**
+ * 追踪通知点击
+ * @param {object} notification - 通知对象
+ */
+ trackClicked(notification) {
+ const { type, priority, id } = notification;
+ const today = this.getTodayDateStr();
+ const clickTime = Date.now();
+
+ // 汇总统计
+ this.metrics.summary.totalClicked++;
+
+ // 按类型统计
+ if (this.metrics.byType[type]) {
+ this.metrics.byType[type].clicked++;
+ }
+
+ // 按优先级统计
+ if (priority && this.metrics.byPriority[priority]) {
+ this.metrics.byPriority[priority].clicked++;
+ }
+
+ // 每日数据
+ this.ensureDailyData(today);
+ this.metrics.dailyData[today].clicked++;
+
+ // 计算响应时间
+ const receivedTime = this.getReceivedTime(id);
+ if (receivedTime) {
+ const responseTime = clickTime - receivedTime;
+ this.metrics.summary.totalResponseTime += responseTime;
+ this.removeReceivedTime(id);
+ logger.debug('notificationMetricsService', 'Response time', { responseTime });
+ }
+
+ this.saveMetrics();
+
+ logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
+ }
+
+ /**
+ * 追踪通知关闭
+ * @param {object} notification - 通知对象
+ */
+ trackDismissed(notification) {
+ const { type, priority, id } = notification;
+ const today = this.getTodayDateStr();
+
+ // 汇总统计
+ this.metrics.summary.totalDismissed++;
+
+ // 按类型统计
+ if (this.metrics.byType[type]) {
+ this.metrics.byType[type].dismissed++;
+ }
+
+ // 按优先级统计
+ if (priority && this.metrics.byPriority[priority]) {
+ this.metrics.byPriority[priority].dismissed++;
+ }
+
+ // 每日数据
+ this.ensureDailyData(today);
+ this.metrics.dailyData[today].dismissed++;
+
+ // 清理接收时间记录
+ this.removeReceivedTime(id);
+
+ this.saveMetrics();
+
+ logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
+ }
+
+ /**
+ * 存储通知接收时间(用于计算响应时间)
+ */
+ storeReceivedTime(id, timestamp) {
+ if (!this.receivedTimes) {
+ this.receivedTimes = new Map();
+ }
+ this.receivedTimes.set(id, timestamp);
+ }
+
+ /**
+ * 获取通知接收时间
+ */
+ getReceivedTime(id) {
+ if (!this.receivedTimes) {
+ return null;
+ }
+ return this.receivedTimes.get(id);
+ }
+
+ /**
+ * 移除通知接收时间记录
+ */
+ removeReceivedTime(id) {
+ if (this.receivedTimes) {
+ this.receivedTimes.delete(id);
+ }
+ }
+
+ /**
+ * 获取汇总统计
+ */
+ getSummary() {
+ const summary = { ...this.metrics.summary };
+
+ // 计算平均响应时间
+ if (summary.totalClicked > 0) {
+ summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
+ } else {
+ summary.avgResponseTime = 0;
+ }
+
+ // 计算点击率
+ if (summary.totalReceived > 0) {
+ summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
+ } else {
+ summary.clickRate = '0.00';
+ }
+
+ // 计算到达率(假设 sent = received)
+ if (summary.totalSent > 0) {
+ summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
+ } else {
+ summary.deliveryRate = '100.00';
+ }
+
+ return summary;
+ }
+
+ /**
+ * 获取按类型统计
+ */
+ getByType() {
+ const result = {};
+ Object.keys(this.metrics.byType).forEach(type => {
+ const data = this.metrics.byType[type];
+ result[type] = {
+ ...data,
+ clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
+ };
+ });
+ return result;
+ }
+
+ /**
+ * 获取按优先级统计
+ */
+ getByPriority() {
+ const result = {};
+ Object.keys(this.metrics.byPriority).forEach(priority => {
+ const data = this.metrics.byPriority[priority];
+ result[priority] = {
+ ...data,
+ clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
+ };
+ });
+ return result;
+ }
+
+ /**
+ * 获取每小时分布
+ */
+ getHourlyDistribution() {
+ return [...this.metrics.hourlyDistribution];
+ }
+
+ /**
+ * 获取每日数据
+ * @param {number} days - 获取最近多少天的数据
+ */
+ getDailyData(days = 7) {
+ const result = [];
+ const today = new Date();
+
+ for (let i = days - 1; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(date.getDate() - i);
+ const dateStr = date.toISOString().split('T')[0];
+
+ const data = this.metrics.dailyData[dateStr] || {
+ sent: 0,
+ received: 0,
+ clicked: 0,
+ dismissed: 0,
+ };
+
+ result.push({
+ date: dateStr,
+ ...data,
+ clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * 获取完整指标数据
+ */
+ getAllMetrics() {
+ return {
+ summary: this.getSummary(),
+ byType: this.getByType(),
+ byPriority: this.getByPriority(),
+ hourlyDistribution: this.getHourlyDistribution(),
+ dailyData: this.getDailyData(30),
+ lastUpdated: this.metrics.lastUpdated,
+ };
+ }
+
+ /**
+ * 重置所有指标
+ */
+ reset() {
+ this.metrics = this.getDefaultMetrics();
+ this.saveMetrics();
+ logger.info('notificationMetricsService', 'Metrics reset');
+ }
+
+ /**
+ * 导出指标数据为 JSON
+ */
+ exportToJSON() {
+ return JSON.stringify(this.getAllMetrics(), null, 2);
+ }
+
+ /**
+ * 导出指标数据为 CSV
+ */
+ exportToCSV() {
+ const summary = this.getSummary();
+ const dailyData = this.getDailyData(30);
+
+ let csv = '# Summary\n';
+ csv += 'Metric,Value\n';
+ csv += `Total Sent,${summary.totalSent}\n`;
+ csv += `Total Received,${summary.totalReceived}\n`;
+ csv += `Total Clicked,${summary.totalClicked}\n`;
+ csv += `Total Dismissed,${summary.totalDismissed}\n`;
+ csv += `Delivery Rate,${summary.deliveryRate}%\n`;
+ csv += `Click Rate,${summary.clickRate}%\n`;
+ csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
+
+ csv += '# Daily Data\n';
+ csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
+ dailyData.forEach(day => {
+ csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
+ });
+
+ return csv;
+ }
+}
+
+// 导出单例
+export const notificationMetricsService = new NotificationMetricsService();
+
+export default notificationMetricsService;
diff --git a/src/services/socketService.js b/src/services/socketService.js
index f3be42b0..ed0eb5b2 100644
--- a/src/services/socketService.js
+++ b/src/services/socketService.js
@@ -187,6 +187,46 @@ class SocketService {
return this.socket?.id || null;
}
+ /**
+ * 手动重连
+ * @returns {boolean} 是否触发重连
+ */
+ reconnect() {
+ if (!this.socket) {
+ logger.warn('socketService', 'Cannot reconnect: socket not initialized');
+ return false;
+ }
+
+ if (this.connected) {
+ logger.info('socketService', 'Already connected, no need to reconnect');
+ return false;
+ }
+
+ logger.info('socketService', 'Manually triggering reconnection...');
+
+ // 重置重连计数
+ this.reconnectAttempts = 0;
+
+ // 触发重连
+ this.socket.connect();
+
+ return true;
+ }
+
+ /**
+ * 获取当前重连尝试次数
+ */
+ getReconnectAttempts() {
+ return this.reconnectAttempts;
+ }
+
+ /**
+ * 获取最大重连次数
+ */
+ getMaxReconnectAttempts() {
+ return this.maxReconnectAttempts;
+ }
+
// ==================== 事件推送专用方法 ====================
/**
diff --git a/src/views/Community/components/EventList.js b/src/views/Community/components/EventList.js
index 01adf502..7736d9cb 100644
--- a/src/views/Community/components/EventList.js
+++ b/src/views/Community/components/EventList.js
@@ -33,6 +33,7 @@ import {
Switch,
FormControl,
FormLabel,
+ useToast,
} from '@chakra-ui/react';
import {
ViewIcon,
@@ -53,6 +54,7 @@ import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
+import { useEventNotifications } from '../../../hooks/useEventNotifications';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
@@ -166,15 +168,55 @@ const PriceArrow = ({ value }) => {
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
+ const toast = useToast();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
+ const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
+
+ // 实时事件推送集成
+ const { isConnected } = useEventNotifications({
+ eventType: 'all',
+ importance: 'all',
+ enabled: true,
+ onNewEvent: (event) => {
+ logger.info('EventList', '收到新事件推送', event);
+
+ // 显示 Toast 通知
+ toast({
+ title: '新事件发布',
+ description: event.title,
+ status: 'info',
+ duration: 5000,
+ isClosable: true,
+ position: 'top-right',
+ variant: 'left-accent',
+ });
+
+ // 将新事件添加到列表顶部(防止重复)
+ setLocalEvents((prevEvents) => {
+ const exists = prevEvents.some(e => e.id === event.id);
+ if (exists) {
+ logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
+ return prevEvents;
+ }
+ logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
+ // 添加到顶部,最多保留 100 个
+ return [event, ...prevEvents].slice(0, 100);
+ });
+ }
+ });
+
+ // 同步外部 events 到 localEvents
+ useEffect(() => {
+ setLocalEvents(events);
+ }, [events]);
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
- events.forEach(ev => {
+ localEvents.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
@@ -197,8 +239,8 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
}
};
loadFollowing();
- // 仅在 events 更新时重跑
- }, [events]);
+ // 仅在 localEvents 更新时重跑
+ }, [localEvents]);
const toggleFollow = async (eventId) => {
try {
@@ -766,8 +808,27 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
return (
- {/* 视图切换控制 */}
-
+ {/* 顶部控制栏:连接状态 + 视图切换 */}
+
+ {/* WebSocket 连接状态指示器 */}
+
+
+ {isConnected ? '🟢 实时推送已开启' : '🔴 实时推送未连接'}
+
+ {isConnected && (
+
+ 新事件将自动推送
+
+ )}
+
+
+ {/* 视图切换控制 */}
精简模式
@@ -781,11 +842,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
- {events.length > 0 ? (
+ {localEvents.length > 0 ? (
- {events.map((event, index) => (
+ {localEvents.map((event, index) => (
- {isCompactMode
+ {isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}