From 09c92731902c07474929b00ddfbed1d460115423 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 21 Oct 2025 17:50:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20sockt=20=E5=BC=B9=E7=AA=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- .gitignore | 9 +- DARK_MODE_TEST.md | 307 ++++++++ ENHANCED_FEATURES_GUIDE.md | 626 ++++++++++++++++ MESSAGE_PUSH_INTEGRATION_TEST.md | 370 ++++++++++ NOTIFICATION_OPTIMIZATION_SUMMARY.md | 280 ++++++++ NOTIFICATION_SYSTEM.md | 75 +- src/App.js | 29 +- src/components/ConnectionStatusBar/index.js | 132 ++++ src/components/NotificationContainer/index.js | 671 ++++++++++++++---- src/constants/notificationTypes.js | 77 +- src/contexts/NotificationContext.js | 345 ++++++++- src/services/mockSocketService.js | 6 +- src/services/notificationHistoryService.js | 402 +++++++++++ src/services/notificationMetricsService.js | 450 ++++++++++++ src/services/socketService.js | 40 ++ src/views/Community/components/EventList.js | 77 +- 17 files changed, 3739 insertions(+), 161 deletions(-) create mode 100644 DARK_MODE_TEST.md create mode 100644 ENHANCED_FEATURES_GUIDE.md create mode 100644 MESSAGE_PUSH_INTEGRATION_TEST.md create mode 100644 NOTIFICATION_OPTIMIZATION_SUMMARY.md create mode 100644 src/components/ConnectionStatusBar/index.js create mode 100644 src/services/notificationHistoryService.js create mode 100644 src/services/notificationMetricsService.js 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 && ( + + )} + + {/* 关闭按钮(仅失败状态显示) */} + {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 && ( - + - + )} 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) }