feat: sockt 弹窗功能添加

This commit is contained in:
zdl
2025-10-21 17:50:21 +08:00
parent c93f689954
commit 09c9273190
17 changed files with 3739 additions and 161 deletions

View File

@@ -1,7 +1,9 @@
{
"permissions": {
"allow": [
"Read(//Users/qiye/**)"
"Read(//Users/qiye/**)",
"Bash(npm run lint:check)",
"Bash(npm run build)"
],
"deny": [],
"ask": []

9
.gitignore vendored
View File

@@ -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/

307
DARK_MODE_TEST.md Normal file
View File

@@ -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. 刷新页面
#### 方法 CChakra 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
```
---
**测试完成后,请反馈效果!** 🎉

626
ENHANCED_FEATURES_GUIDE.md Normal file
View File

@@ -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 (
<div>
<p>当前状态: {browserPermission}</p>
<button onClick={requestBrowserPermission}>
开启桌面通知
</button>
</div>
);
}
```
### 通知分发策略
| 优先级 | 页面在前台 | 页面在后台 |
|-------|----------|----------|
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
| 重要 | 网页通知 | 桌面通知 |
| 普通 | 网页通知 | 网页通知 |
### 测试步骤
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 (
<div>
{/* 汇总卡片 */}
<StatsCard title="总推送数" value={summary.totalSent} />
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
{/* 类型分布饼图 */}
<PieChart data={Object.entries(byType).map(([type, data]) => ({
name: type,
value: data.received
}))} />
{/* 每日趋势折线图 */}
<LineChart data={dailyData} />
</div>
);
}
```
---
## 📜 功能 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

View File

@@ -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
<Switch
isChecked={enableNotifications}
onChange={handleToggle}
>
启用实时通知
</Switch>
```
### 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 onNewEventToast + 列表更新)
```
---
## ✅ 整合完成
所有代码和功能已经就绪!你现在可以:
1. ✅ 在 Mock 模式下测试实时推送
2. ✅ 在 Real 模式下连接后端
3. ✅ 查看右下角通知卡片
4. ✅ 体验事件列表实时更新
5. ✅ 随时切换 Mock/Real 模式
**祝测试顺利!🎉**

View File

@@ -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;
}
`;
// 应用到紧急通知
<Box
animation={priority === PRIORITY_LEVELS.URGENT
? `${pulseAnimation} 2s ease-in-out infinite`
: undefined}
...
/>
```
#### 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 && (
<Icon as={typeConfig.icon} ... />
)}
};
```
## 后续改进建议
### 短期
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
- [ ] 提供配置选项让用户自定义显示元素
### 长期
- [ ] 支持通知分组(按类型或优先级)
- [ ] 添加通知搜索和筛选功能
- [ ] 通知历史记录可视化统计
## 构建状态
✅ 构建成功 (npm run build)
✅ 无语法错误
✅ 无 TypeScript 错误

View File

@@ -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条"展开按钮

View File

@@ -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 (
<ConnectionStatusBar
status={connectionStatus}
reconnectAttempt={reconnectAttempt}
maxReconnectAttempts={5}
onRetry={retryConnection}
onClose={handleClose}
/>
);
}
function AppContent() {
const { colorMode } = useColorMode();
return (
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */}
<ScrollToTop />
<Routes>

View File

@@ -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 (
<Slide
direction="top"
in={shouldShow}
style={{ zIndex: 10000 }}
>
<Alert
status={config.status}
variant="subtle"
bg={bg}
borderBottom="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={3}
px={{ base: 4, md: 6 }}
>
<AlertIcon />
<Box flex="1">
<HStack spacing={2} align="center" flexWrap="wrap">
<AlertTitle fontSize="sm" fontWeight="bold" mb={0}>
{config.title}
</AlertTitle>
<AlertDescription fontSize="sm" mb={0}>
{config.description}
</AlertDescription>
</HStack>
</Box>
{/* 重试按钮(仅失败状态显示) */}
{status === CONNECTION_STATUS.FAILED && onRetry && (
<Button
size="sm"
colorScheme="red"
leftIcon={<MdRefresh />}
onClick={onRetry}
mr={2}
flexShrink={0}
>
立即重试
</Button>
)}
{/* 关闭按钮(仅失败状态显示) */}
{status === CONNECTION_STATUS.FAILED && onClose && (
<CloseButton
onClick={onClose}
size="sm"
flexShrink={0}
/>
)}
</Alert>
</Slide>
);
};
export default ConnectionStatusBar;

View File

@@ -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 (
<ScaleFade initialScale={0.9} in={true}>
<Box
bg={bgColor}
borderLeft="4px solid"
borderColor={borderColor}
// 无障碍属性
role={priority === 'urgent' ? 'alert' : 'status'}
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
aria-atomic="true"
aria-label={ariaDescription}
tabIndex={isActuallyClickable ? 0 : -1}
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
// 样式属性
bg={colors.bg}
borderLeft={`${priorityBorderWidth} solid`}
borderColor={colors.border}
// 可点击的通知添加完整边框提示
{...(isActuallyClickable && {
border: '1px solid',
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
})}
borderRadius="md"
boxShadow={isNewest ? '2xl' : 'lg'}
p={4}
w="400px" // 统一宽度
// 可点击的通知使用更明显的阴影(悬浮感)
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 ? 'pointer' : 'default'} // 严格判断
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
_hover={isActuallyClickable ? {
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: hoverBg,
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: borderColor,
borderRightColor: colors.border,
borderTop: '1px solid',
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
})}
>
{/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
{/* 头部区域:标题 + 可选标识 */}
<HStack spacing={2} align="start" mb={2}>
{/* 类型图标 */}
{/* 类型图标 - 仅紧急和重要通知显示 */}
{shouldShowIcon && (
<Icon
as={typeConfig.icon}
w={5}
h={5}
color={typeConfig.iconColor}
color={colors.icon} // 使用响应式颜色
mt={0.5}
flexShrink={0}
/>
)}
{/* 标题 */}
<Text
fontSize="sm"
fontWeight="bold"
color={textColor}
color={colors.text}
lineHeight="short"
flex={1}
noOfLines={2}
noOfLines={{ base: 1, md: 2 }}
pl={shouldShowIcon ? 0 : 0}
>
{title}
</Text>
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
</Badge>
)}
{/* 预测标识 */}
{isPrediction && (
<Badge
colorScheme="gray"
size="sm"
flexShrink={0}
>
预测
</Badge>
)}
{/* AI 生成标识 */}
{isAIGenerated && (
<Badge
colorScheme="purple"
size="sm"
flexShrink={0}
>
AI
</Badge>
)}
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
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,
}}
/>
</HStack>
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
{/* 内容区域 */}
<Text
fontSize="sm"
color={subTextColor}
color={colors.subText}
lineHeight="short"
noOfLines={3}
noOfLines={{ base: 2, md: 3 }}
mb={3}
pl={7} // 与图标对齐
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
>
{content}
</Text>
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
<HStack
spacing={2}
fontSize="xs"
color={metaTextColor}
pl={7} // 与图标对齐
color={colors.metaText}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
flexWrap="wrap"
>
{/* 作者信息(仅分析报告) */}
{author && (
<HStack spacing={1}>
<Text>👤</Text>
<Text>{author.name} - {author.organization}</Text>
<Text>|</Text>
</HStack>
)}
{/* 时间信息 */}
<HStack spacing={1}>
<Text>📅</Text>
<Icon as={MdAccessTime} w={3} h={3} />
<Text>
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
</Text>
</HStack>
{/* 状态提示(仅预测通知) */}
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
{isAIGenerated && (
<>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<Badge
colorScheme="purple"
size="xs"
display={{ base: "none", sm: "inline-flex" }}
>
AI
</Badge>
</>
)}
{/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
{isPrediction && (
<>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", sm: "flex" }}>
<Badge colorScheme="gray" size="xs">预测</Badge>
{extra?.statusHint && (
<>
<Text>|</Text>
<HStack spacing={1} color="gray.400">
<Icon as={MdSchedule} w={3} h={3} />
<Text>{extra.statusHint}</Text>
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
<Text color="gray.400">{extra.statusHint}</Text>
</>
)}
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知) */}
{/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
{author && (
<>
<Text display={{ base: "none", md: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", md: "flex" }}>
<Icon as={MdPerson} w={3} h={3} />
<Text>{author.name} - {author.organization}</Text>
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知)*/}
{isActuallyClickable && (
<>
<Text>|</Text>
<HStack spacing={1}>
{/* Loading 时显示 Spinner否则显示图标 */}
{isNavigating ? (
<Spinner size="xs" />
) : (
<Icon as={MdOpenInNew} w={3} h={3} />
<Text>查看详情</Text>
)}
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
<Text color={isNavigating ? 'blue.500' : undefined}>
{isNavigating ? '跳转中...' : '查看详情'}
</Text>
</HStack>
</>
)}
</HStack>
</Box>
</ScaleFade>
);
};
}, (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);
// 使用带过期时间的 localStorage2分钟 = 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 (
<Box
role="region"
aria-label={containerAriaLabel}
position="fixed"
bottom={6}
right={6}
bottom={{ base: 3, md: 28 }}
right={{ base: 3, md: 6 }}
zIndex={9999}
pointerEvents="none"
>
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
align="flex-end"
pointerEvents="auto"
>
<AnimatePresence mode="popLayout">
{visibleNotifications.map((notification, index) => (
<Slide
<motion.div
key={notification.id}
direction="bottom"
in={true}
layout // 自动处理位置变化(流畅重排)
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 300, scale: 0.9 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
}}
style={{
position: 'relative',
zIndex: 9999 - index, // 最新消息index=0z-index最高
zIndex: 9999 - index,
}}
>
<NotificationItem
notification={notification}
onClose={removeNotification}
isNewest={index === 0} // 第一条消息是最新的
isNewest={index === 0}
/>
</Slide>
</motion.div>
))}
</AnimatePresence>
{/* 折叠/展开按钮 */}
{hasMore && (
<ScaleFade initialScale={0.9} in={true}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
>
<Button
size="sm"
size={{ base: "xs", md: "sm" }}
variant="solid"
bg={collapseBg}
color={collapseTextColor}
_hover={{ bg: collapseHoverBg }}
_focus={{
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '2px',
}}
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
boxShadow="md"
borderRadius="md"
>
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
</Button>
</ScaleFade>
</motion.div>
)}
</VStack>
</Box>

View File

@@ -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,
};

View File

@@ -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 }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
>
<HStack spacing={3} align="start">
<Box flex={1}>
<Text fontWeight="bold" mb={1}>
{newNotification.title}
</Text>
<Text fontSize="sm" opacity={0.9}>
💡 开启桌面通知以便后台接收
</Text>
</Box>
<Button
size="sm"
colorScheme="whiteAlpha"
onClick={() => {
requestBrowserPermission();
onClose();
}}
>
开启
</Button>
<CloseButton onClick={onClose} />
</HStack>
</Box>
),
});
}
}
}
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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
// ==================== 事件推送专用方法 ====================
/**

View File

@@ -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 (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 顶部控制栏:连接状态 + 视图切换 */}
<Flex justify="space-between" align="center" mb={6}>
{/* WebSocket 连接状态指示器 */}
<HStack spacing={2}>
<Badge
colorScheme={isConnected ? 'green' : 'red'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时推送已开启' : '🔴 实时推送未连接'}
</Badge>
{isConnected && (
<Text fontSize="xs" color={mutedColor}>
新事件将自动推送
</Text>
)}
</HStack>
{/* 视图切换控制 */}
<Flex justify="flex-end" mb={6}>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
@@ -781,9 +842,9 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</FormControl>
</Flex>
{events.length > 0 ? (
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
{events.map((event, index) => (
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event)