feat: sockt 弹窗功能添加
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Read(//Users/qiye/**)"
|
"Read(//Users/qiye/**)",
|
||||||
|
"Bash(npm run lint:check)",
|
||||||
|
"Bash(npm run build)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -39,4 +39,11 @@ pnpm-debug.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Windows
|
# 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
307
DARK_MODE_TEST.md
Normal 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. 刷新页面
|
||||||
|
|
||||||
|
#### 方法 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**测试完成后,请反馈效果!** 🎉
|
||||||
626
ENHANCED_FEATURES_GUIDE.md
Normal file
626
ENHANCED_FEATURES_GUIDE.md
Normal 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
|
||||||
370
MESSAGE_PUSH_INTEGRATION_TEST.md
Normal file
370
MESSAGE_PUSH_INTEGRATION_TEST.md
Normal 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 onNewEvent(Toast + 列表更新)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 整合完成
|
||||||
|
|
||||||
|
所有代码和功能已经就绪!你现在可以:
|
||||||
|
|
||||||
|
1. ✅ 在 Mock 模式下测试实时推送
|
||||||
|
2. ✅ 在 Real 模式下连接后端
|
||||||
|
3. ✅ 查看右下角通知卡片
|
||||||
|
4. ✅ 体验事件列表实时更新
|
||||||
|
5. ✅ 随时切换 Mock/Real 模式
|
||||||
|
|
||||||
|
**祝测试顺利!🎉**
|
||||||
280
NOTIFICATION_OPTIMIZATION_SUMMARY.md
Normal file
280
NOTIFICATION_OPTIMIZATION_SUMMARY.md
Normal 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 错误
|
||||||
@@ -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秒
|
- ✅ **按优先级区分自动关闭**:紧急通知不自动关闭,重要30秒,普通15秒
|
||||||
- ✅ **简单折叠机制**:最多显示3条通知,超过显示"还有X条"展开按钮
|
- ✅ **简单折叠机制**:最多显示3条通知,超过显示"还有X条"展开按钮
|
||||||
|
|||||||
29
src/App.js
29
src/App.js
@@ -43,22 +43,49 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
|||||||
// Contexts
|
// Contexts
|
||||||
import { AuthProvider } from "contexts/AuthContext";
|
import { AuthProvider } from "contexts/AuthContext";
|
||||||
import { AuthModalProvider } from "contexts/AuthModalContext";
|
import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||||
import { NotificationProvider } from "contexts/NotificationContext";
|
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import ProtectedRoute from "components/ProtectedRoute";
|
import ProtectedRoute from "components/ProtectedRoute";
|
||||||
import ErrorBoundary from "components/ErrorBoundary";
|
import ErrorBoundary from "components/ErrorBoundary";
|
||||||
import AuthModalManager from "components/Auth/AuthModalManager";
|
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||||
import NotificationContainer from "components/NotificationContainer";
|
import NotificationContainer from "components/NotificationContainer";
|
||||||
|
import ConnectionStatusBar from "components/ConnectionStatusBar";
|
||||||
import NotificationTestTool from "components/NotificationTestTool";
|
import NotificationTestTool from "components/NotificationTestTool";
|
||||||
import ScrollToTop from "components/ScrollToTop";
|
import ScrollToTop from "components/ScrollToTop";
|
||||||
import { logger } from "utils/logger";
|
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() {
|
function AppContent() {
|
||||||
const { colorMode } = useColorMode();
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||||
|
{/* Socket 连接状态条 */}
|
||||||
|
<ConnectionStatusBarWrapper />
|
||||||
|
|
||||||
{/* 路由切换时自动滚动到顶部 */}
|
{/* 路由切换时自动滚动到顶部 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
132
src/components/ConnectionStatusBar/index.js
Normal file
132
src/components/ConnectionStatusBar/index.js
Normal 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;
|
||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -14,25 +14,249 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Spinner,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
Slide,
|
|
||||||
ScaleFade,
|
|
||||||
} from '@chakra-ui/react';
|
} 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 { useNotification } from '../../contexts/NotificationContext';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_CONFIGS,
|
NOTIFICATION_TYPE_CONFIGS,
|
||||||
NOTIFICATION_TYPES,
|
NOTIFICATION_TYPES,
|
||||||
PRIORITY_CONFIGS,
|
PRIORITY_CONFIGS,
|
||||||
|
PRIORITY_LEVELS,
|
||||||
NOTIFICATION_CONFIG,
|
NOTIFICATION_CONFIG,
|
||||||
formatNotificationTime,
|
formatNotificationTime,
|
||||||
|
getPriorityBgOpacity,
|
||||||
|
getPriorityBorderWidth,
|
||||||
} from '../../constants/notificationTypes';
|
} 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 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;
|
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
|
||||||
|
|
||||||
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
|
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
|
||||||
@@ -51,77 +275,177 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
|||||||
...typeConfig,
|
...typeConfig,
|
||||||
icon: typeConfig.getIcon(priceChange),
|
icon: typeConfig.getIcon(priceChange),
|
||||||
colorScheme: typeConfig.getColorScheme(priceChange),
|
colorScheme: typeConfig.getColorScheme(priceChange),
|
||||||
|
// 亮色模式
|
||||||
bg: typeConfig.getBg(priceChange),
|
bg: typeConfig.getBg(priceChange),
|
||||||
borderColor: typeConfig.getBorderColor(priceChange),
|
borderColor: typeConfig.getBorderColor(priceChange),
|
||||||
iconColor: typeConfig.getIconColor(priceChange),
|
iconColor: typeConfig.getIconColor(priceChange),
|
||||||
hoverBg: typeConfig.getHoverBg(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 priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
|
||||||
|
|
||||||
const bgColor = useColorModeValue(typeConfig.bg, `${typeConfig.colorScheme}.900`);
|
// 判断是否显示图标(仅紧急和重要通知)
|
||||||
const borderColor = useColorModeValue(typeConfig.borderColor, `${typeConfig.colorScheme}.500`);
|
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
|
||||||
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 handleClick = () => {
|
const priorityBorderWidth = getPriorityBorderWidth(priority);
|
||||||
if (isActuallyClickable) {
|
const isDark = useColorModeValue(false, true);
|
||||||
navigate(link);
|
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 (
|
return (
|
||||||
<ScaleFade initialScale={0.9} in={true}>
|
<Box
|
||||||
<Box
|
// 无障碍属性
|
||||||
bg={bgColor}
|
role={priority === 'urgent' ? 'alert' : 'status'}
|
||||||
borderLeft="4px solid"
|
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
|
||||||
borderColor={borderColor}
|
aria-atomic="true"
|
||||||
borderRadius="md"
|
aria-label={ariaDescription}
|
||||||
boxShadow={isNewest ? '2xl' : 'lg'}
|
tabIndex={isActuallyClickable ? 0 : -1}
|
||||||
p={4}
|
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
|
||||||
w="400px" // 统一宽度
|
// 样式属性
|
||||||
position="relative"
|
bg={colors.bg}
|
||||||
cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
|
borderLeft={`${priorityBorderWidth} solid`}
|
||||||
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
|
borderColor={colors.border}
|
||||||
_hover={isActuallyClickable ? {
|
// 可点击的通知添加完整边框提示
|
||||||
boxShadow: 'xl',
|
{...(isActuallyClickable && {
|
||||||
transform: 'translateY(-2px)',
|
border: '1px solid',
|
||||||
bg: hoverBg,
|
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
|
||||||
} : {}} // 不可点击时无 hover 效果
|
})}
|
||||||
transition="all 0.2s"
|
borderRadius="md"
|
||||||
{...(isNewest && {
|
// 可点击的通知使用更明显的阴影(悬浮感)
|
||||||
borderRight: '1px solid',
|
boxShadow={isActuallyClickable
|
||||||
borderRightColor: borderColor,
|
? (isNewest ? '2xl' : 'md')
|
||||||
borderTop: '1px solid',
|
: (isNewest ? 'xl' : 'sm')}
|
||||||
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
|
// 紧急通知添加脉冲动画
|
||||||
})}
|
animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
|
||||||
>
|
p={{ base: 3, md: 4 }}
|
||||||
{/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
|
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`),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* 头部区域:标题 + 可选标识 */}
|
||||||
<HStack spacing={2} align="start" mb={2}>
|
<HStack spacing={2} align="start" mb={2}>
|
||||||
{/* 类型图标 */}
|
{/* 类型图标 - 仅紧急和重要通知显示 */}
|
||||||
<Icon
|
{shouldShowIcon && (
|
||||||
as={typeConfig.icon}
|
<Icon
|
||||||
w={5}
|
as={typeConfig.icon}
|
||||||
h={5}
|
w={5}
|
||||||
color={typeConfig.iconColor}
|
h={5}
|
||||||
mt={0.5}
|
color={colors.icon} // 使用响应式颜色
|
||||||
flexShrink={0}
|
mt={0.5}
|
||||||
/>
|
flexShrink={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={textColor}
|
color={colors.text}
|
||||||
lineHeight="short"
|
lineHeight="short"
|
||||||
flex={1}
|
flex={1}
|
||||||
noOfLines={2}
|
noOfLines={{ base: 1, md: 2 }}
|
||||||
|
pl={shouldShowIcon ? 0 : 0}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 预测标识 */}
|
|
||||||
{isPrediction && (
|
|
||||||
<Badge
|
|
||||||
colorScheme="gray"
|
|
||||||
size="sm"
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
预测
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI 生成标识 */}
|
|
||||||
{isAIGenerated && (
|
|
||||||
<Badge
|
|
||||||
colorScheme="purple"
|
|
||||||
size="sm"
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
AI
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<MdClose />}
|
icon={<MdClose />}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorScheme={typeConfig.colorScheme}
|
colorScheme={typeConfig.colorScheme}
|
||||||
aria-label="关闭通知"
|
aria-label={`关闭通知:${title}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose(id);
|
onClose(id);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: closeButtonHoverBg,
|
bg: colors.closeButtonHoverBg,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
|||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
color={subTextColor}
|
color={colors.subText}
|
||||||
lineHeight="short"
|
lineHeight="short"
|
||||||
noOfLines={3}
|
noOfLines={{ base: 2, md: 3 }}
|
||||||
mb={3}
|
mb={3}
|
||||||
pl={7} // 与图标对齐
|
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
|||||||
<HStack
|
<HStack
|
||||||
spacing={2}
|
spacing={2}
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
color={metaTextColor}
|
color={colors.metaText}
|
||||||
pl={7} // 与图标对齐
|
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
{/* 作者信息(仅分析报告) */}
|
|
||||||
{author && (
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text>👤</Text>
|
|
||||||
<Text>{author.name} - {author.organization}</Text>
|
|
||||||
<Text>|</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 时间信息 */}
|
{/* 时间信息 */}
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Text>📅</Text>
|
<Icon as={MdAccessTime} w={3} h={3} />
|
||||||
<Text>
|
<Text>
|
||||||
{publishTime && formatNotificationTime(publishTime)}
|
{publishTime && formatNotificationTime(publishTime)}
|
||||||
{!publishTime && pushTime && formatNotificationTime(pushTime)}
|
{!publishTime && pushTime && formatNotificationTime(pushTime)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* 状态提示(仅预测通知) */}
|
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
|
||||||
{extra?.statusHint && (
|
{isAIGenerated && (
|
||||||
<>
|
<>
|
||||||
<Text>|</Text>
|
<Text display={{ base: "none", sm: "inline" }}>|</Text>
|
||||||
<HStack spacing={1} color="gray.400">
|
<Badge
|
||||||
<Icon as={MdSchedule} w={3} h={3} />
|
colorScheme="purple"
|
||||||
<Text>{extra.statusHint}</Text>
|
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 && (
|
||||||
|
<>
|
||||||
|
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
|
||||||
|
<Text color="gray.400">{extra.statusHint}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</HStack>
|
</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 && (
|
{isActuallyClickable && (
|
||||||
<>
|
<>
|
||||||
<Text>|</Text>
|
<Text>|</Text>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Icon as={MdOpenInNew} w={3} h={3} />
|
{/* Loading 时显示 Spinner,否则显示图标 */}
|
||||||
<Text>查看详情</Text>
|
{isNavigating ? (
|
||||||
|
<Spinner size="xs" />
|
||||||
|
) : (
|
||||||
|
<Icon as={MdOpenInNew} w={3} h={3} />
|
||||||
|
)}
|
||||||
|
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
|
||||||
|
<Text color={isNavigating ? 'blue.500' : undefined}>
|
||||||
|
{isNavigating ? '跳转中...' : '查看详情'}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
</ScaleFade>
|
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
|
||||||
|
return (
|
||||||
|
prevProps.notification.id === nextProps.notification.id &&
|
||||||
|
prevProps.isNewest === nextProps.isNewest
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知容器组件 - 主组件
|
* 通知容器组件 - 主组件
|
||||||
*/
|
*/
|
||||||
const NotificationContainer = () => {
|
const NotificationContainer = () => {
|
||||||
const { notifications, removeNotification } = useNotification();
|
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) {
|
if (notifications.length === 0) {
|
||||||
@@ -265,11 +652,18 @@ const NotificationContainer = () => {
|
|||||||
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||||
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||||
|
|
||||||
|
// 构建无障碍描述
|
||||||
|
const containerAriaLabel = hasMore
|
||||||
|
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航,Enter键或空格键查看详情。`
|
||||||
|
: `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航,Enter键或空格键查看详情。`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
role="region"
|
||||||
|
aria-label={containerAriaLabel}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
bottom={6}
|
bottom={{ base: 3, md: 28 }}
|
||||||
right={6}
|
right={{ base: 3, md: 6 }}
|
||||||
zIndex={9999}
|
zIndex={9999}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
|
|||||||
align="flex-end"
|
align="flex-end"
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
{visibleNotifications.map((notification, index) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<Slide
|
{visibleNotifications.map((notification, index) => (
|
||||||
key={notification.id}
|
<motion.div
|
||||||
direction="bottom"
|
key={notification.id}
|
||||||
in={true}
|
layout // 自动处理位置变化(流畅重排)
|
||||||
style={{
|
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||||
position: 'relative',
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
zIndex: 9999 - index, // 最新消息(index=0)z-index最高
|
exit={{ opacity: 0, x: 300, scale: 0.9 }}
|
||||||
}}
|
transition={{
|
||||||
>
|
type: "spring",
|
||||||
<NotificationItem
|
stiffness: 300,
|
||||||
notification={notification}
|
damping: 30,
|
||||||
onClose={removeNotification}
|
mass: 0.8,
|
||||||
isNewest={index === 0} // 第一条消息是最新的
|
}}
|
||||||
/>
|
style={{
|
||||||
</Slide>
|
position: 'relative',
|
||||||
))}
|
zIndex: 9999 - index,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NotificationItem
|
||||||
|
notification={notification}
|
||||||
|
onClose={removeNotification}
|
||||||
|
isNewest={index === 0}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 折叠/展开按钮 */}
|
{/* 折叠/展开按钮 */}
|
||||||
{hasMore && (
|
{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
|
<Button
|
||||||
size="sm"
|
size={{ base: "xs", md: "sm" }}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
bg={collapseBg}
|
bg={collapseBg}
|
||||||
color={collapseTextColor}
|
color={collapseTextColor}
|
||||||
_hover={{ bg: collapseHoverBg }}
|
_hover={{ bg: collapseHoverBg }}
|
||||||
|
_focus={{
|
||||||
|
outline: '2px solid',
|
||||||
|
outlineColor: 'blue.500',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
}}
|
||||||
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
|
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
>
|
>
|
||||||
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
|
|||||||
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
|
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</ScaleFade>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -58,30 +58,66 @@ export const PRIORITY_CONFIGS = {
|
|||||||
[PRIORITY_LEVELS.URGENT]: {
|
[PRIORITY_LEVELS.URGENT]: {
|
||||||
label: '紧急',
|
label: '紧急',
|
||||||
colorScheme: 'red',
|
colorScheme: 'red',
|
||||||
show: true,
|
show: false, // 不再显示标签,改用边框+背景色表示
|
||||||
|
borderWidth: '6px', // 紧急:粗边框
|
||||||
|
bgOpacity: 0.25, // 紧急:深色背景
|
||||||
|
darkBgOpacity: 0.30, // 暗色模式下更明显
|
||||||
},
|
},
|
||||||
[PRIORITY_LEVELS.IMPORTANT]: {
|
[PRIORITY_LEVELS.IMPORTANT]: {
|
||||||
label: '重要',
|
label: '重要',
|
||||||
colorScheme: 'orange',
|
colorScheme: 'orange',
|
||||||
show: true,
|
show: false, // 不再显示标签,改用边框+背景色表示
|
||||||
|
borderWidth: '4px', // 重要:中等边框
|
||||||
|
bgOpacity: 0.15, // 重要:中色背景
|
||||||
|
darkBgOpacity: 0.20, // 暗色模式
|
||||||
},
|
},
|
||||||
[PRIORITY_LEVELS.NORMAL]: {
|
[PRIORITY_LEVELS.NORMAL]: {
|
||||||
label: '',
|
label: '',
|
||||||
colorScheme: 'gray',
|
colorScheme: 'gray',
|
||||||
show: false, // 普通优先级不显示标签
|
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 = {
|
export const NOTIFICATION_TYPE_CONFIGS = {
|
||||||
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
|
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
|
||||||
name: '公告通知',
|
name: '公告通知',
|
||||||
icon: MdCampaign,
|
icon: MdCampaign,
|
||||||
colorScheme: 'blue',
|
colorScheme: 'blue',
|
||||||
|
// 亮色模式
|
||||||
bg: 'blue.50',
|
bg: 'blue.50',
|
||||||
borderColor: 'blue.400',
|
borderColor: 'blue.400',
|
||||||
iconColor: 'blue.500',
|
iconColor: 'blue.500',
|
||||||
hoverBg: 'blue.100',
|
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]: {
|
[NOTIFICATION_TYPES.STOCK_ALERT]: {
|
||||||
name: '股票动向',
|
name: '股票动向',
|
||||||
@@ -95,6 +131,7 @@ export const NOTIFICATION_TYPE_CONFIGS = {
|
|||||||
if (!priceChange) return 'red';
|
if (!priceChange) return 'red';
|
||||||
return priceChange.startsWith('+') ? 'red' : 'green';
|
return priceChange.startsWith('+') ? 'red' : 'green';
|
||||||
},
|
},
|
||||||
|
// 亮色模式
|
||||||
getBg: (priceChange) => {
|
getBg: (priceChange) => {
|
||||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||||
return `${scheme}.50`;
|
return `${scheme}.50`;
|
||||||
@@ -111,24 +148,58 @@ export const NOTIFICATION_TYPE_CONFIGS = {
|
|||||||
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
|
||||||
return `${scheme}.100`;
|
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]: {
|
[NOTIFICATION_TYPES.EVENT_ALERT]: {
|
||||||
name: '事件动向',
|
name: '事件动向',
|
||||||
icon: MdArticle,
|
icon: MdArticle,
|
||||||
colorScheme: 'orange',
|
colorScheme: 'orange',
|
||||||
|
// 亮色模式
|
||||||
bg: 'orange.50',
|
bg: 'orange.50',
|
||||||
borderColor: 'orange.400',
|
borderColor: 'orange.400',
|
||||||
iconColor: 'orange.500',
|
iconColor: 'orange.500',
|
||||||
hoverBg: 'orange.100',
|
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]: {
|
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
|
||||||
name: '分析报告',
|
name: '分析报告',
|
||||||
icon: MdAssessment,
|
icon: MdAssessment,
|
||||||
colorScheme: 'purple',
|
colorScheme: 'purple',
|
||||||
|
// 亮色模式
|
||||||
bg: 'purple.50',
|
bg: 'purple.50',
|
||||||
borderColor: 'purple.400',
|
borderColor: 'purple.400',
|
||||||
iconColor: 'purple.500',
|
iconColor: 'purple.500',
|
||||||
hoverBg: 'purple.100',
|
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,
|
PRIORITY_CONFIGS,
|
||||||
NOTIFICATION_TYPE_CONFIGS,
|
NOTIFICATION_TYPE_CONFIGS,
|
||||||
formatNotificationTime,
|
formatNotificationTime,
|
||||||
|
getPriorityBgOpacity,
|
||||||
|
getPriorityBorderWidth,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
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 { logger } from '../utils/logger';
|
||||||
import socket, { SOCKET_TYPE } from '../services/socket';
|
import socket, { SOCKET_TYPE } from '../services/socket';
|
||||||
import notificationSound from '../assets/sounds/notification.wav';
|
import notificationSound from '../assets/sounds/notification.wav';
|
||||||
import { browserNotificationService } from '../services/browserNotificationService';
|
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();
|
const NotificationContext = createContext();
|
||||||
@@ -24,10 +35,17 @@ export const useNotification = () => {
|
|||||||
|
|
||||||
// 通知提供者组件
|
// 通知提供者组件
|
||||||
export const NotificationProvider = ({ children }) => {
|
export const NotificationProvider = ({ children }) => {
|
||||||
|
const toast = useToast();
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
|
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);
|
const audioRef = useRef(null);
|
||||||
|
|
||||||
// 初始化音频
|
// 初始化音频
|
||||||
@@ -63,10 +81,19 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
/**
|
/**
|
||||||
* 移除通知
|
* 移除通知
|
||||||
* @param {string} id - 通知ID
|
* @param {string} id - 通知ID
|
||||||
|
* @param {boolean} wasClicked - 是否是因为点击而关闭
|
||||||
*/
|
*/
|
||||||
const removeNotification = useCallback((id) => {
|
const removeNotification = useCallback((id, wasClicked = false) => {
|
||||||
logger.info('NotificationContext', 'Removing notification', { id });
|
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
|
||||||
setNotifications(prev => prev.filter(notif => notif.id !== id));
|
|
||||||
|
// 监控埋点:追踪关闭(非点击的情况)
|
||||||
|
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');
|
logger.info('NotificationContext', 'Requesting browser notification permission');
|
||||||
const permission = await browserNotificationService.requestPermission();
|
const permission = await browserNotificationService.requestPermission();
|
||||||
setBrowserPermission(permission);
|
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;
|
return permission;
|
||||||
}, []);
|
}, [toast]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送浏览器通知
|
* 发送浏览器通知
|
||||||
@@ -138,10 +189,82 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||||
}, [browserPermission]);
|
}, [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) => {
|
const addWebNotification = useCallback((newNotification) => {
|
||||||
|
// 监控埋点:追踪通知接收
|
||||||
|
notificationMetricsService.trackReceived(newNotification);
|
||||||
|
|
||||||
|
// 保存到历史记录
|
||||||
|
notificationHistoryService.saveNotification(newNotification);
|
||||||
|
|
||||||
// 新消息插入到数组开头,最多保留 maxHistory 条
|
// 新消息插入到数组开头,最多保留 maxHistory 条
|
||||||
setNotifications(prev => {
|
setNotifications(prev => {
|
||||||
const updated = [newNotification, ...prev];
|
const updated = [newNotification, ...prev];
|
||||||
@@ -174,7 +297,7 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
* 添加通知到队列
|
* 添加通知到队列
|
||||||
* @param {object} notification - 通知对象
|
* @param {object} notification - 通知对象
|
||||||
*/
|
*/
|
||||||
const addNotification = useCallback((notification) => {
|
const addNotification = useCallback(async (notification) => {
|
||||||
// 根据优先级获取自动关闭时长
|
// 根据优先级获取自动关闭时长
|
||||||
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
|
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
|
||||||
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
||||||
@@ -193,6 +316,62 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
|
|
||||||
logger.info('NotificationContext', 'Adding notification', newNotification);
|
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; // 页面是否在后台
|
const isPageHidden = document.hidden; // 页面是否在后台
|
||||||
|
|
||||||
// ========== 智能分发策略 ==========
|
// ========== 智能分发策略 ==========
|
||||||
@@ -224,7 +403,7 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return newNotification.id;
|
return newNotification.id;
|
||||||
}, [sendBrowserNotification, addWebNotification]);
|
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||||
|
|
||||||
// 连接到 Socket 服务
|
// 连接到 Socket 服务
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,8 +415,20 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||||
|
setReconnectAttempt(0);
|
||||||
logger.info('NotificationContext', 'Socket connected');
|
logger.info('NotificationContext', 'Socket connected');
|
||||||
|
|
||||||
|
// 显示重连成功提示(如果之前断开过)
|
||||||
|
if (connectionStatus !== CONNECTION_STATUS.CONNECTED) {
|
||||||
|
toast({
|
||||||
|
title: '已重新连接',
|
||||||
|
status: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 如果使用 mock,可以启动定期推送
|
// 如果使用 mock,可以启动定期推送
|
||||||
if (SOCKET_TYPE === 'MOCK') {
|
if (SOCKET_TYPE === 'MOCK') {
|
||||||
// 启动模拟推送:使用配置的间隔和数量
|
// 启动模拟推送:使用配置的间隔和数量
|
||||||
@@ -247,18 +438,48 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', (reason) => {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
logger.warn('NotificationContext', 'Socket disconnected');
|
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||||
|
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听交易通知
|
// 监听连接错误
|
||||||
socket.on('trade_notification', (data) => {
|
socket.on('connect_error', (error) => {
|
||||||
logger.info('NotificationContext', 'Received trade notification', data);
|
logger.error('NotificationContext', 'Socket connect_error', error);
|
||||||
addNotification(data);
|
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) => {
|
socket.on('system_notification', (data) => {
|
||||||
logger.info('NotificationContext', 'Received system notification', data);
|
logger.info('NotificationContext', 'Received system notification', data);
|
||||||
addNotification(data);
|
addNotification(data);
|
||||||
@@ -275,22 +496,111 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
|
|
||||||
socket.off('connect');
|
socket.off('connect');
|
||||||
socket.off('disconnect');
|
socket.off('disconnect');
|
||||||
socket.off('trade_notification');
|
socket.off('connect_error');
|
||||||
|
socket.off('reconnect_failed');
|
||||||
|
socket.off('new_event');
|
||||||
socket.off('system_notification');
|
socket.off('system_notification');
|
||||||
socket.disconnect();
|
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 = {
|
const value = {
|
||||||
notifications,
|
notifications,
|
||||||
isConnected,
|
isConnected,
|
||||||
soundEnabled,
|
soundEnabled,
|
||||||
browserPermission,
|
browserPermission,
|
||||||
|
connectionStatus,
|
||||||
|
reconnectAttempt,
|
||||||
addNotification,
|
addNotification,
|
||||||
removeNotification,
|
removeNotification,
|
||||||
clearAllNotifications,
|
clearAllNotifications,
|
||||||
toggleSound,
|
toggleSound,
|
||||||
requestBrowserPermission,
|
requestBrowserPermission,
|
||||||
|
trackNotificationClick,
|
||||||
|
retryConnection,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -300,4 +610,7 @@ export const NotificationProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 导出连接状态枚举供外部使用
|
||||||
|
export { CONNECTION_STATUS };
|
||||||
|
|
||||||
export default NotificationContext;
|
export default NotificationContext;
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ class MockSocketService {
|
|||||||
|
|
||||||
// 在连接后3秒发送欢迎消息
|
// 在连接后3秒发送欢迎消息
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.emit('trade_notification', {
|
this.emit('new_event', {
|
||||||
type: 'system_notification',
|
type: 'system_notification',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
title: '连接成功',
|
title: '连接成功',
|
||||||
@@ -445,7 +445,7 @@ class MockSocketService {
|
|||||||
|
|
||||||
// 延迟发送(模拟层叠效果)
|
// 延迟发送(模拟层叠效果)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.emit('trade_notification', alert);
|
this.emit('new_event', alert);
|
||||||
logger.info('mockSocketService', 'Mock notification sent', alert);
|
logger.info('mockSocketService', 'Mock notification sent', alert);
|
||||||
}, i * 500); // 每条消息间隔500ms
|
}, i * 500); // 每条消息间隔500ms
|
||||||
}
|
}
|
||||||
@@ -478,7 +478,7 @@ class MockSocketService {
|
|||||||
id: `test_${Date.now()}`,
|
id: `test_${Date.now()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.emit('trade_notification', notification);
|
this.emit('new_event', notification);
|
||||||
logger.info('mockSocketService', 'Test notification sent', notification);
|
logger.info('mockSocketService', 'Test notification sent', notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
402
src/services/notificationHistoryService.js
Normal file
402
src/services/notificationHistoryService.js
Normal 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;
|
||||||
450
src/services/notificationMetricsService.js
Normal file
450
src/services/notificationMetricsService.js
Normal 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;
|
||||||
@@ -187,6 +187,46 @@ class SocketService {
|
|||||||
return this.socket?.id || null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 事件推送专用方法 ====================
|
// ==================== 事件推送专用方法 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
ViewIcon,
|
ViewIcon,
|
||||||
@@ -53,6 +54,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { getApiBase } from '../../../utils/apiConfig';
|
import { getApiBase } from '../../../utils/apiConfig';
|
||||||
|
import { useEventNotifications } from '../../../hooks/useEventNotifications';
|
||||||
|
|
||||||
// ========== 工具函数定义在组件外部 ==========
|
// ========== 工具函数定义在组件外部 ==========
|
||||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||||
@@ -166,15 +168,55 @@ const PriceArrow = ({ value }) => {
|
|||||||
// ========== 主组件 ==========
|
// ========== 主组件 ==========
|
||||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||||
const [followingMap, setFollowingMap] = useState({});
|
const [followingMap, setFollowingMap] = useState({});
|
||||||
const [followCountMap, setFollowCountMap] = 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(() => {
|
useEffect(() => {
|
||||||
// 初始化计数映射
|
// 初始化计数映射
|
||||||
const initCounts = {};
|
const initCounts = {};
|
||||||
events.forEach(ev => {
|
localEvents.forEach(ev => {
|
||||||
initCounts[ev.id] = ev.follower_count || 0;
|
initCounts[ev.id] = ev.follower_count || 0;
|
||||||
});
|
});
|
||||||
setFollowCountMap(initCounts);
|
setFollowCountMap(initCounts);
|
||||||
@@ -197,8 +239,8 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadFollowing();
|
loadFollowing();
|
||||||
// 仅在 events 更新时重跑
|
// 仅在 localEvents 更新时重跑
|
||||||
}, [events]);
|
}, [localEvents]);
|
||||||
|
|
||||||
const toggleFollow = async (eventId) => {
|
const toggleFollow = async (eventId) => {
|
||||||
try {
|
try {
|
||||||
@@ -766,8 +808,27 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
|||||||
return (
|
return (
|
||||||
<Box bg={bgColor} minH="100vh" py={8}>
|
<Box bg={bgColor} minH="100vh" py={8}>
|
||||||
<Container maxW="container.xl">
|
<Container maxW="container.xl">
|
||||||
{/* 视图切换控制 */}
|
{/* 顶部控制栏:连接状态 + 视图切换 */}
|
||||||
<Flex justify="flex-end" mb={6}>
|
<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>
|
||||||
|
|
||||||
|
{/* 视图切换控制 */}
|
||||||
<FormControl display="flex" alignItems="center" w="auto">
|
<FormControl display="flex" alignItems="center" w="auto">
|
||||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||||
精简模式
|
精简模式
|
||||||
@@ -781,11 +842,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{events.length > 0 ? (
|
{localEvents.length > 0 ? (
|
||||||
<VStack align="stretch" spacing={0}>
|
<VStack align="stretch" spacing={0}>
|
||||||
{events.map((event, index) => (
|
{localEvents.map((event, index) => (
|
||||||
<Box key={event.id} position="relative">
|
<Box key={event.id} position="relative">
|
||||||
{isCompactMode
|
{isCompactMode
|
||||||
? renderCompactEvent(event)
|
? renderCompactEvent(event)
|
||||||
: renderDetailedEvent(event)
|
: renderDetailedEvent(event)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user