Compare commits
28 Commits
origin_pro
...
3eb31c99dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb31c99dc | ||
|
|
5f6b4b083b | ||
|
|
905023c056 | ||
|
|
25cc28e03b | ||
|
|
5f9901a098 | ||
|
|
28643d7c4a | ||
|
|
bb28e141e6 | ||
|
|
8fa273c8d4 | ||
|
|
17c04211bb | ||
|
|
c9419d3c14 | ||
|
|
dfc13c5737 | ||
|
|
de8d0ef1c3 | ||
|
|
65c16d65ac | ||
|
|
13a291b979 | ||
|
|
4d6da77aeb | ||
|
|
fc1f667700 | ||
|
|
46639030bb | ||
|
|
f747a0bdb2 | ||
|
|
9b55610167 | ||
|
|
a93fcfa9b9 | ||
|
|
8914a46c40 | ||
|
|
678eb6838e | ||
|
|
c06d3a88ae | ||
|
|
307c308739 | ||
|
|
cbb6517bb1 | ||
|
|
f33489f5d7 | ||
|
|
9ff77b570d | ||
|
|
de37546ddb |
@@ -44,7 +44,7 @@
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **状态管理**: Redux Toolkit 2.9.2
|
||||
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
|
||||
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化
|
||||
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
@@ -161,13 +161,8 @@ module.exports = {
|
||||
);
|
||||
}
|
||||
|
||||
// 忽略 moment 的语言包(如果项目使用了 moment)
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^\.\/locale$/,
|
||||
contextRegExp: /moment$/,
|
||||
})
|
||||
);
|
||||
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
|
||||
// 如果需要优化,可以只导入需要的语言包
|
||||
|
||||
// ============== Loader 优化 ==============
|
||||
const babelLoaderRule = webpackConfig.module.rules.find(
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
# 通知系统增强功能 - 使用指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南介绍通知系统的三大增强功能:
|
||||
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
|
||||
@@ -1,371 +0,0 @@
|
||||
# 消息推送系统整合 - 测试指南
|
||||
|
||||
## 📋 整合完成清单
|
||||
|
||||
✅ **统一事件名称**
|
||||
- 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/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
后端创建事件
|
||||
↓
|
||||
后端轮询检测(30秒)
|
||||
↓
|
||||
Socket.IO 推送 new_event
|
||||
↓
|
||||
前端 socketService 接收
|
||||
↓
|
||||
NotificationContext 监听并适配
|
||||
↓
|
||||
同时触发:
|
||||
├─ NotificationContainer(右下角卡片)
|
||||
└─ EventList onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
@@ -1,280 +0,0 @@
|
||||
# 消息推送系统优化总结
|
||||
|
||||
## 优化目标
|
||||
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 错误
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,338 +0,0 @@
|
||||
# 崩溃修复测试指南
|
||||
|
||||
> 测试时间:2025-10-14
|
||||
> 测试范围:SignInIllustration.js + SignUpIllustration.js
|
||||
> 服务器地址:http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证以下修复是否有效:
|
||||
- ✅ 响应对象崩溃(6处)
|
||||
- ✅ 组件卸载后 setState(6处)
|
||||
- ✅ 定时器内存泄漏(2处)
|
||||
|
||||
---
|
||||
|
||||
## 📋 测试清单
|
||||
|
||||
### ✅ 关键测试(必做)
|
||||
|
||||
#### 1. **网络异常测试** - 验证响应对象修复
|
||||
|
||||
**登录页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-in
|
||||
2. 切换到"验证码登录"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 打开浏览器开发者工具 (F12) → Network 标签
|
||||
5. 点击 Offline 模拟断网
|
||||
6. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
|
||||
修复前:
|
||||
❌ 页面白屏崩溃
|
||||
❌ Console 报错:Cannot read property 'json' of null
|
||||
```
|
||||
|
||||
**登录页面 - 微信登录**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面,保持断网状态
|
||||
2. 点击"扫码登录"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
```
|
||||
|
||||
**注册页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-up
|
||||
2. 切换到"验证码注册"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 保持断网状态
|
||||
5. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送失败 - 网络请求失败..."
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **组件卸载测试** - 验证内存泄漏修复
|
||||
|
||||
**倒计时中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 恢复网络连接
|
||||
2. 在登录页面输入手机号并发送验证码
|
||||
3. 等待倒计时开始(60秒倒计时)
|
||||
4. 立即点击浏览器后退按钮或切换到其他页面
|
||||
5. 打开 Console 查看是否有警告
|
||||
|
||||
预期结果:
|
||||
✅ 无警告:"Can't perform a React state update on an unmounted component"
|
||||
✅ 倒计时定时器正确清理
|
||||
✅ 无内存泄漏
|
||||
|
||||
修复前:
|
||||
❌ Console 警告:Memory leak warning
|
||||
❌ setState 在组件卸载后仍被调用
|
||||
```
|
||||
|
||||
**请求进行中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在注册页面填写完整信息
|
||||
2. 点击"注册"按钮
|
||||
3. 在请求响应前(loading 状态)快速刷新页面或关闭标签页
|
||||
4. 打开新标签页查看 Console
|
||||
|
||||
预期结果:
|
||||
✅ 无崩溃
|
||||
✅ 无警告信息
|
||||
✅ 请求被正确取消或忽略
|
||||
```
|
||||
|
||||
**注册成功跳转前离开**
|
||||
```
|
||||
测试步骤:
|
||||
1. 完成注册提交
|
||||
2. 在显示"注册成功"提示后
|
||||
3. 立即关闭标签页(不等待2秒自动跳转)
|
||||
|
||||
预期结果:
|
||||
✅ 无警告
|
||||
✅ navigate 不会在组件卸载后执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **边界情况测试** - 验证数据完整性检查
|
||||
|
||||
**后端返回空响应**
|
||||
```
|
||||
测试步骤(需要模拟后端):
|
||||
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
|
||||
2. 修改响应为空对象 {}
|
||||
3. 观察页面反应
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"服务器响应为空"
|
||||
✅ 不会尝试访问 undefined 属性
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
**后端返回 500 错误**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面点击"扫码登录"
|
||||
2. 如果后端返回 500 错误
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"获取二维码失败:HTTP 500"
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧪 进阶测试(推荐)
|
||||
|
||||
#### 4. **弱网环境测试**
|
||||
|
||||
**慢速网络模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. Chrome DevTools → Network → Throttling → Slow 3G
|
||||
2. 尝试发送验证码
|
||||
3. 等待 10 秒(超时时间)
|
||||
|
||||
预期结果:
|
||||
✅ 10秒后显示超时错误
|
||||
✅ 不会无限等待
|
||||
✅ 用户可以重试
|
||||
```
|
||||
|
||||
**丢包模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. 使用 Chrome DevTools 模拟丢包
|
||||
2. 连续点击"发送验证码"多次
|
||||
|
||||
预期结果:
|
||||
✅ 每次请求都有适当的错误提示
|
||||
✅ 不会因为并发请求而崩溃
|
||||
✅ 按钮在请求期间正确禁用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. **定时器清理测试**
|
||||
|
||||
**倒计时清理验证**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面发送验证码
|
||||
2. 等待倒计时到 50 秒
|
||||
3. 快速切换到注册页面
|
||||
4. 再切换回登录页面
|
||||
5. 观察倒计时是否重置
|
||||
|
||||
预期结果:
|
||||
✅ 定时器在页面切换时正确清理
|
||||
✅ 返回登录页面时倒计时重新开始(如果再次发送)
|
||||
✅ 没有多个定时器同时运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. **并发请求测试**
|
||||
|
||||
**快速连续点击**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面输入手机号
|
||||
2. 快速连续点击"发送验证码"按钮 5 次
|
||||
|
||||
预期结果:
|
||||
✅ 只发送一次请求(按钮在请求期间禁用)
|
||||
✅ 不会因为并发而崩溃
|
||||
✅ 正确显示 loading 状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 监控指标
|
||||
|
||||
### Console 检查清单
|
||||
|
||||
在测试过程中,打开 Console (F12) 监控以下内容:
|
||||
|
||||
```
|
||||
✅ 无红色错误(Error)
|
||||
✅ 无内存泄漏警告(Memory leak warning)
|
||||
✅ 无 setState 警告(Can't perform a React state update...)
|
||||
✅ 无 undefined 访问错误(Cannot read property of undefined)
|
||||
```
|
||||
|
||||
### Network 检查清单
|
||||
|
||||
打开 Network 标签监控:
|
||||
|
||||
```
|
||||
✅ 请求超时时间:10秒
|
||||
✅ 失败请求有正确的错误处理
|
||||
✅ 没有重复的请求
|
||||
✅ 请求被正确取消(如果页面卸载)
|
||||
```
|
||||
|
||||
### Performance 检查清单
|
||||
|
||||
打开 Performance 标签(可选):
|
||||
|
||||
```
|
||||
✅ 无内存泄漏(Memory 不会持续增长)
|
||||
✅ 定时器正确清理(Timer count 正确)
|
||||
✅ EventListener 正确清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试记录表
|
||||
|
||||
请在测试时填写以下表格:
|
||||
|
||||
| 测试项 | 状态 | 问题描述 | 截图 |
|
||||
|--------|------|---------|------|
|
||||
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如何报告问题
|
||||
|
||||
如果发现问题,请提供:
|
||||
|
||||
1. **测试场景**:具体的测试步骤
|
||||
2. **预期结果**:应该发生什么
|
||||
3. **实际结果**:实际发生了什么
|
||||
4. **Console 错误**:完整的错误信息
|
||||
5. **截图/录屏**:问题的视觉证明
|
||||
6. **环境信息**:
|
||||
- 浏览器版本
|
||||
- 操作系统
|
||||
- 网络状态
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试完成检查
|
||||
|
||||
测试完成后,确认以下内容:
|
||||
|
||||
```
|
||||
□ 所有关键测试通过
|
||||
□ Console 无错误
|
||||
□ Network 请求正常
|
||||
□ 无内存泄漏警告
|
||||
□ 用户体验流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速测试命令
|
||||
|
||||
```bash
|
||||
# 1. 确认服务器运行
|
||||
curl http://localhost:3000
|
||||
|
||||
# 2. 打开浏览器测试
|
||||
open http://localhost:3000/auth/sign-in
|
||||
|
||||
# 3. 查看编译日志
|
||||
tail -f /tmp/react-build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 测试页面链接
|
||||
|
||||
- **登录页面**: http://localhost:3000/auth/sign-in
|
||||
- **注册页面**: http://localhost:3000/auth/sign-up
|
||||
- **首页**: http://localhost:3000/home
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发者工具快捷键
|
||||
|
||||
```
|
||||
F12 - 打开开发者工具
|
||||
Ctrl/Cmd+R - 刷新页面
|
||||
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
|
||||
Ctrl/Cmd+Shift+C - 元素选择器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**测试时间**:2025-10-14
|
||||
**预计测试时长**:15-30 分钟
|
||||
**建议测试人员**:开发者 + QA
|
||||
|
||||
祝测试顺利!如发现问题请及时反馈。
|
||||
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -6,9 +6,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/theme-tools": "^1.3.6",
|
||||
"@chakra-ui/icons": "^2.2.6",
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -39,9 +40,8 @@
|
||||
"history": "^5.3.0",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" layout="admin">
|
||||
<html lang="zh-CN" dir="ltr" layout="admin">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
@@ -7,6 +7,177 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!-- 基本 SEO -->
|
||||
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
|
||||
<meta name="description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析,助力投资决策" />
|
||||
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
|
||||
<link rel="canonical" href="https://valuefrontier.cn/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://valuefrontier.cn/" />
|
||||
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta property="og:description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析" />
|
||||
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
<meta property="og:site_name" content="价值前沿" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://valuefrontier.cn/" />
|
||||
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta name="twitter:description" content="基于金融大语言模型,实时监控股市行情、a股、美股" />
|
||||
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
|
||||
<!-- SEO 增强 -->
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||
<meta name="author" content="价值前沿团队" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
|
||||
<!-- 性能优化: DNS 预连接 -->
|
||||
<link rel="preconnect" href="https://valuefrontier.cn" />
|
||||
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
|
||||
|
||||
<!-- JSON-LD 结构化数据: 组织信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"logo": "https://valuefrontier.cn/logo.png",
|
||||
"description": "基于金融大语言模型的智能舆情分析平台",
|
||||
"foundingDate": "2023",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "Customer Service",
|
||||
"availableLanguage": ["zh-CN"]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://valuefrontier.cn"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "金融AI舆情分析系统,实时监控股市行情",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "价值前沿",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "基于金融大语言模型,实时监控股市行情、a股、美股,提供企业舆情分析",
|
||||
"offers": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "专业版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "198",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "旗舰版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "998",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "1250",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"featureList": [
|
||||
"实时舆情监控",
|
||||
"智能事件分析",
|
||||
"多维度数据可视化",
|
||||
"AI驱动的投资建议",
|
||||
"行业板块分析"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 面包屑导航 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "首页",
|
||||
"item": "https://valuefrontier.cn/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "事件中心",
|
||||
"item": "https://valuefrontier.cn/community"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "概念分析",
|
||||
"item": "https://valuefrontier.cn/concepts"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "个股分析",
|
||||
"item": "https://valuefrontier.cn/stocks"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link
|
||||
@@ -15,10 +186,19 @@
|
||||
href="%PUBLIC_URL%/apple-icon.png"
|
||||
/>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
|
||||
<title>价值前沿——LLM赋能的分析平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<noscript>
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
|
||||
<div>
|
||||
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
|
||||
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
|
||||
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
|
||||
请在浏览器设置中启用 JavaScript,然后刷新页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
76
src/App.js
76
src/App.js
@@ -9,8 +9,9 @@
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Routes
|
||||
import AppRoutes from './routes';
|
||||
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
*/
|
||||
function AppContent() {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
@@ -43,6 +56,67 @@ function AppContent() {
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
}, [location.search, location.pathname]);
|
||||
|
||||
// ✅ 页面浏览时长追踪
|
||||
useEffect(() => {
|
||||
// 计算上一个页面的停留时长
|
||||
const calculateAndTrackDuration = () => {
|
||||
const exitTime = Date.now();
|
||||
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 路由切换时追踪上一个页面的时长
|
||||
if (currentPathRef.current !== location.pathname) {
|
||||
calculateAndTrackDuration();
|
||||
|
||||
// 更新为新页面
|
||||
currentPathRef.current = location.pathname;
|
||||
pageEnterTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
// 页面关闭/刷新时追踪时长
|
||||
const handleBeforeUnload = () => {
|
||||
calculateAndTrackDuration();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [location.pathname, isAuthenticated]);
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
|
||||
@@ -356,24 +356,22 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// ✅ 兼容后端两种命名格式:camelCase (isNewUser) 和 snake_case (is_new_user)
|
||||
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
title: isNewUser ? '注册成功' : '登录成功',
|
||||
description: config.successDescription,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
logger.info('AuthFormContent', '登录成功', {
|
||||
isNewUser: data.isNewUser,
|
||||
userId: data.user?.id
|
||||
});
|
||||
|
||||
// 检查是否为新注册用户
|
||||
if (data.isNewUser) {
|
||||
if (isNewUser) {
|
||||
// 新注册用户,延迟后显示昵称设置引导
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
import { ACTIVATION_EVENTS } from '@lib/constants';
|
||||
|
||||
/**
|
||||
* 全局认证弹窗管理器
|
||||
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
|
||||
closeModal
|
||||
} = useAuthModal();
|
||||
|
||||
// ✅ 追踪弹窗打开次数(用于漏斗分析)
|
||||
const hasTrackedOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthModalOpen && !hasTrackedOpen.current) {
|
||||
// ✅ 使用异步追踪,不阻塞渲染
|
||||
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
modal_type: 'auth_modal',
|
||||
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
|
||||
});
|
||||
|
||||
hasTrackedOpen.current = true;
|
||||
}
|
||||
|
||||
// ✅ 弹窗关闭时重置标记(允许再次追踪)
|
||||
if (!isAuthModalOpen) {
|
||||
hasTrackedOpen.current = false;
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const now = dayjs();
|
||||
const time = dayjs(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
@@ -50,7 +50,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -111,7 +111,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -182,7 +182,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && times.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
|
||||
@@ -357,7 +357,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置(重要修复)
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && dates.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 找到事件发生日期或最接近的交易日
|
||||
|
||||
204
src/constants/tracking.js
Normal file
204
src/constants/tracking.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/constants/tracking.js
|
||||
// PostHog 事件追踪优先级配置
|
||||
|
||||
/**
|
||||
* 事件优先级枚举
|
||||
*
|
||||
* 用于决定事件的追踪时机,优化性能和用户体验。
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const EVENT_PRIORITY = {
|
||||
/**
|
||||
* 关键事件 - 立即发送,不可延迟
|
||||
* 示例:登录、注册、支付、订阅购买
|
||||
*/
|
||||
CRITICAL: 'critical',
|
||||
|
||||
/**
|
||||
* 高优先级事件 - 立即发送
|
||||
* 示例:详情打开、搜索提交、关注操作、分享操作
|
||||
*/
|
||||
HIGH: 'high',
|
||||
|
||||
/**
|
||||
* 普通优先级事件 - 空闲时发送
|
||||
* 示例:列表查看、筛选应用、排序变更
|
||||
*/
|
||||
NORMAL: 'normal',
|
||||
|
||||
/**
|
||||
* 低优先级事件 - 空闲时发送,可批量合并
|
||||
* 示例:鼠标移动、滚动事件、hover 事件
|
||||
*/
|
||||
LOW: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Community 页面(新闻催化分析)事件优先级映射
|
||||
*
|
||||
* 映射规则:
|
||||
* - CRITICAL: 无(Community 页面无关键业务操作)
|
||||
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
|
||||
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
|
||||
* - LOW: 暂未使用
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
export const COMMUNITY_EVENT_PRIORITIES = {
|
||||
// ==================== 普通优先级(空闲时追踪)====================
|
||||
|
||||
/**
|
||||
* 页面浏览事件 - NORMAL
|
||||
* 触发时机:用户进入 Community 页面
|
||||
* 延迟原因:页面加载时避免阻塞渲染
|
||||
*/
|
||||
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻列表查看 - NORMAL
|
||||
* 触发时机:新闻列表加载完成
|
||||
* 延迟原因:避免阻塞列表渲染
|
||||
*/
|
||||
'News List Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻筛选应用 - NORMAL
|
||||
* 触发时机:用户应用筛选条件(重要性、日期、行业)
|
||||
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Filter Applied': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻排序变更 - NORMAL
|
||||
* 触发时机:用户切换排序方式(最新、最热、收益率)
|
||||
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Sorted': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻标签页点击 - NORMAL
|
||||
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
|
||||
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
|
||||
*/
|
||||
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
// ==================== 高优先级(立即追踪)====================
|
||||
|
||||
/**
|
||||
* 新闻文章点击 - HIGH
|
||||
* 触发时机:用户点击新闻卡片
|
||||
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
|
||||
*/
|
||||
'News Article Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 新闻详情打开 - HIGH
|
||||
* 触发时机:打开新闻详情弹窗或页面
|
||||
* 立即追踪原因:关键交互操作,需要准确记录查看时间
|
||||
*/
|
||||
'News Detail Opened': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索查询提交 - HIGH
|
||||
* 触发时机:用户提交搜索关键词
|
||||
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
|
||||
*/
|
||||
'Search Query Submitted': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索无结果 - HIGH
|
||||
* 触发时机:搜索返回 0 个结果
|
||||
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
|
||||
*/
|
||||
'Search No Results': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关股票点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关股票
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Stock Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关概念点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关概念
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Concept Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件关注操作 - HIGH
|
||||
* 触发时机:用户点击关注按钮
|
||||
* 立即追踪原因:关键业务操作,需要准确记录关注行为
|
||||
*/
|
||||
'Event Followed': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件取消关注 - HIGH
|
||||
* 触发时机:用户取消关注事件
|
||||
* 立即追踪原因:关键业务操作,需要准确记录取关原因
|
||||
*/
|
||||
'Event Unfollowed': EVENT_PRIORITY.HIGH,
|
||||
};
|
||||
|
||||
/**
|
||||
* requestIdleCallback 配置
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const IDLE_CALLBACK_CONFIG = {
|
||||
/**
|
||||
* 超时时间(毫秒)
|
||||
* 即使浏览器不空闲,也会在此时间后强制执行追踪
|
||||
*
|
||||
* 设置为 2000ms 的原因:
|
||||
* - 足够长:避免在用户快速操作时阻塞主线程
|
||||
* - 足够短:确保用户快速关闭页面前也能发送事件
|
||||
* - 平衡点:2 秒是用户注意力的典型持续时间
|
||||
*/
|
||||
timeout: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取事件优先级
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW)
|
||||
*/
|
||||
export const getEventPriority = (eventName) => {
|
||||
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否需要立即追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否立即追踪
|
||||
*/
|
||||
export const shouldTrackImmediately = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否可以延迟追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否可以延迟追踪
|
||||
*/
|
||||
export const canTrackIdle = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
EVENT_PRIORITY,
|
||||
COMMUNITY_EVENT_PRIORITIES,
|
||||
IDLE_CALLBACK_CONFIG,
|
||||
getEventPriority,
|
||||
shouldTrackImmediately,
|
||||
canTrackIdle,
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
|
||||
if (prevUser && prevUser.id === data.user.id) {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
role: data.user.role,
|
||||
registration_date: data.user.created_at
|
||||
});
|
||||
|
||||
return data.user;
|
||||
});
|
||||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||||
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Logged In' 或 'User Signed Up'
|
||||
// 属性名:login_method (不是 loginType)
|
||||
|
||||
// ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突
|
||||
// toast({
|
||||
// title: "登录成功",
|
||||
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Signed Up'(不是 'user_registered')
|
||||
// 属性名:login_method(不是 method)
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 邮箱注册
|
||||
const registerWithEmail = async (email, code, username, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`/api/auth/register/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
username,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'registerWithEmail', error);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送手机验证码
|
||||
const sendSmsCode = async (phone) => {
|
||||
try {
|
||||
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const sendEmailCode = async (email) => {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/send-email-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '发送失败');
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast
|
||||
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast
|
||||
logger.error('AuthContext', 'sendEmailCode', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// 登出方法
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
|
||||
updateUser,
|
||||
login,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
sendEmailCode,
|
||||
logout,
|
||||
hasRole,
|
||||
refreshSession,
|
||||
|
||||
@@ -124,6 +124,7 @@ async function startApp() {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
// ✅ StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router
|
||||
|
||||
@@ -33,8 +33,8 @@ export const initPostHog = () => {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
// Pageview tracking - auto-capture for DAU/MAU analytics
|
||||
capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
|
||||
// Session Recording Configuration
|
||||
@@ -185,6 +185,30 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 异步追踪事件(不阻塞主线程)
|
||||
* 使用 requestIdleCallback 在浏览器空闲时发送事件
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
export const trackEventAsync = (eventName, properties = {}) => {
|
||||
// 浏览器支持 requestIdleCallback 时使用(推荐)
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
trackEvent(eventName, properties);
|
||||
},
|
||||
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
|
||||
);
|
||||
} else {
|
||||
// 降级方案:使用 setTimeout(兼容性更好)
|
||||
setTimeout(() => {
|
||||
trackEvent(eventName, properties);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
|
||||
@@ -53,3 +53,13 @@ export type {
|
||||
CommentAuthor,
|
||||
CreateCommentParams,
|
||||
} from './comment';
|
||||
|
||||
// 投资规划相关类型
|
||||
export type {
|
||||
EventType,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
InvestmentEvent,
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
148
src/types/investment.ts
Normal file
148
src/types/investment.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 投资规划相关类型定义
|
||||
* 用于 InvestmentPlanningCenter 组件及其子组件
|
||||
*/
|
||||
|
||||
import { UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件类型枚举
|
||||
*/
|
||||
export type EventType = 'plan' | 'review' | 'reminder' | 'analysis';
|
||||
|
||||
/**
|
||||
* 事件来源
|
||||
*/
|
||||
export type EventSource = 'user' | 'future' | 'system';
|
||||
|
||||
/**
|
||||
* 事件状态
|
||||
*/
|
||||
export type EventStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* 投资事件接口
|
||||
* 表示日历中的投资计划、复盘或其他事件
|
||||
*/
|
||||
export interface InvestmentEvent {
|
||||
/** 事件唯一标识符 */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 事件描述/详细内容 */
|
||||
description?: string;
|
||||
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
event_date: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 事件来源(用户创建/系统生成/未来事件) */
|
||||
source?: EventSource;
|
||||
|
||||
/** 重要度 (1-5) */
|
||||
importance?: number;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks?: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status?: EventStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
|
||||
/** 事件内容(用于计划/复盘的详细内容) */
|
||||
content?: string;
|
||||
|
||||
/** 日期字段(兼容旧数据) */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据类型
|
||||
* 用于创建/编辑投资计划或复盘
|
||||
*/
|
||||
export interface PlanFormData {
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
date: string;
|
||||
|
||||
/** 标题 */
|
||||
title: string;
|
||||
|
||||
/** 内容/描述 */
|
||||
content: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning Context 值类型
|
||||
* 用于在 InvestmentPlanningCenter 的子组件间共享数据
|
||||
*/
|
||||
export interface PlanningContextValue {
|
||||
/** 所有事件列表 */
|
||||
allEvents: InvestmentEvent[];
|
||||
|
||||
/** 设置事件列表 */
|
||||
setAllEvents: React.Dispatch<React.SetStateAction<InvestmentEvent[]>>;
|
||||
|
||||
/** 重新加载所有数据 */
|
||||
loadAllData: () => Promise<void>;
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 设置加载状态 */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
|
||||
activeTab: number;
|
||||
|
||||
/** 设置激活的标签页 */
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
/** Chakra UI Toast 实例 */
|
||||
toast: {
|
||||
(options?: UseToastOptions): string | number | undefined;
|
||||
close: (id: string | number) => void;
|
||||
closeAll: (options?: { positions?: Array<'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'> }) => void;
|
||||
update: (id: string | number, options: Omit<UseToastOptions, 'id'>) => void;
|
||||
isActive: (id: string | number) => boolean;
|
||||
};
|
||||
|
||||
// 颜色主题变量(基于当前主题模式)
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
}
|
||||
337
src/utils/trackingHelpers.js
Normal file
337
src/utils/trackingHelpers.js
Normal file
@@ -0,0 +1,337 @@
|
||||
// src/utils/trackingHelpers.js
|
||||
// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件
|
||||
|
||||
import { shouldTrackImmediately } from '../constants/tracking';
|
||||
|
||||
/**
|
||||
* requestIdleCallback Polyfill
|
||||
* Safari 和旧浏览器不支持 requestIdleCallback,使用 setTimeout 降级
|
||||
*
|
||||
* @param {Function} callback - 回调函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.timeout - 超时时间(毫秒)
|
||||
* @returns {number} 定时器 ID
|
||||
*/
|
||||
const requestIdleCallbackPolyfill = (callback, options = {}) => {
|
||||
const timeout = options.timeout || 2000;
|
||||
const start = Date.now();
|
||||
|
||||
return setTimeout(() => {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* cancelIdleCallback Polyfill
|
||||
*
|
||||
* @param {number} id - 定时器 ID
|
||||
*/
|
||||
const cancelIdleCallbackPolyfill = (id) => {
|
||||
clearTimeout(id);
|
||||
};
|
||||
|
||||
// 使用原生 API 或 polyfill
|
||||
const requestIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.requestIdleCallback
|
||||
? window.requestIdleCallback.bind(window)
|
||||
: requestIdleCallbackPolyfill;
|
||||
|
||||
const cancelIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.cancelIdleCallback
|
||||
? window.cancelIdleCallback.bind(window)
|
||||
: cancelIdleCallbackPolyfill;
|
||||
|
||||
// ==================== 待发送事件队列 ====================
|
||||
|
||||
/**
|
||||
* 待发送事件队列(用于批量发送优化)
|
||||
* @type {Array<{trackFn: Function, args: Array}>}
|
||||
*/
|
||||
let pendingEvents = [];
|
||||
|
||||
/**
|
||||
* 已调度的 idle callback ID(防止重复调度)
|
||||
* @type {number|null}
|
||||
*/
|
||||
let scheduledCallbackId = null;
|
||||
|
||||
/**
|
||||
* 刷新待发送事件队列
|
||||
* 立即执行所有待发送的追踪事件
|
||||
*/
|
||||
const flushPendingEvents = () => {
|
||||
if (pendingEvents.length === 0) return;
|
||||
|
||||
const eventsToFlush = [...pendingEvents];
|
||||
pendingEvents = [];
|
||||
|
||||
eventsToFlush.forEach(({ trackFn, args }) => {
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to flush event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`,
|
||||
'color: #10B981; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理空闲时执行待发送事件
|
||||
*
|
||||
* @param {IdleDeadline} deadline - 空闲时间信息
|
||||
*/
|
||||
const processIdleEvents = (deadline) => {
|
||||
scheduledCallbackId = null;
|
||||
|
||||
// 如果超时或队列为空,强制刷新
|
||||
if (deadline.didTimeout || pendingEvents.length === 0) {
|
||||
flushPendingEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// 在空闲时间内尽可能多地处理事件
|
||||
while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) {
|
||||
const { trackFn, args } = pendingEvents.shift();
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有未处理的事件,继续调度
|
||||
if (pendingEvents.length > 0) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 在浏览器空闲时追踪事件(非关键事件优化)
|
||||
*
|
||||
* 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行,
|
||||
* 避免阻塞主线程,提升页面交互响应速度。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 页面浏览事件(page_viewed)
|
||||
* - 列表查看事件(list_viewed)
|
||||
* - 筛选/排序事件(filter_applied, sorted)
|
||||
* - 低优先级交互事件
|
||||
*
|
||||
* **不适用场景**:
|
||||
* - 关键业务事件(登录、支付、关注)
|
||||
* - 用户明确操作事件(按钮点击、详情打开)
|
||||
* - 需要实时追踪的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView)
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventIdle } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 延迟追踪页面浏览事件
|
||||
* trackEventIdle(trackEvent, 'page_viewed', { page: '/community' });
|
||||
*
|
||||
* // 延迟追踪筛选事件
|
||||
* trackEventIdle(track, 'news_filter_applied', { importance: 'high' });
|
||||
*/
|
||||
export const trackEventIdle = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到待发送队列
|
||||
pendingEvents.push({ trackFn, args });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⏱️ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`,
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有已调度的 callback,调度一个新的
|
||||
if (scheduledCallbackId === null) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000, // 2秒超时保护,确保事件不会无限延迟
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即追踪事件(关键事件)
|
||||
*
|
||||
* 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 关键业务事件(登录、注册、支付、订阅)
|
||||
* - 用户明确操作(按钮点击、详情打开、搜索提交)
|
||||
* - 高优先级交互事件(关注、分享、评论)
|
||||
* - 需要准确时序的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventImmediate } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 立即追踪登录事件
|
||||
* trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' });
|
||||
*
|
||||
* // 立即追踪详情打开事件
|
||||
* trackEventImmediate(track, 'news_detail_opened', { news_id: 123 });
|
||||
*/
|
||||
export const trackEventImmediate = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
trackFn(...args);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⚡ [trackingHelpers] Event tracked immediately`,
|
||||
'color: #F59E0B; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event immediately:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能追踪包装器
|
||||
*
|
||||
* 根据事件优先级自动选择立即追踪或空闲时追踪。
|
||||
* 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 业务代码不需要关心事件优先级细节
|
||||
* - 统一的追踪接口,自动优化性能
|
||||
* - 易于维护和扩展
|
||||
*
|
||||
* **优先级规则**(由 `src/constants/tracking.js` 配置):
|
||||
* - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`)
|
||||
* - NORMAL / LOW → 空闲时追踪(`trackEventIdle`)
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`)
|
||||
* @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级)
|
||||
* @param {Object} properties - 事件属性
|
||||
*
|
||||
* @example
|
||||
* import { smartTrack } from '@/utils/trackingHelpers';
|
||||
* import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
*
|
||||
* const { track } = usePostHogTrack();
|
||||
*
|
||||
* // 自动根据优先级选择追踪方式
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 });
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 });
|
||||
*/
|
||||
export const smartTrack = (trackFn, eventName, properties = {}) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventName || typeof eventName !== 'string') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据事件优先级选择追踪方式
|
||||
if (shouldTrackImmediately(eventName)) {
|
||||
// 高优先级事件:立即追踪
|
||||
trackEventImmediate(trackFn, eventName, properties);
|
||||
} else {
|
||||
// 普通优先级事件:空闲时追踪
|
||||
trackEventIdle(trackFn, eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面卸载前刷新所有待发送事件
|
||||
*
|
||||
* 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。
|
||||
* 防止用户快速关闭页面时丢失事件数据。
|
||||
*
|
||||
* **使用方式**:
|
||||
* ```javascript
|
||||
* import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers';
|
||||
*
|
||||
* useEffect(() => {
|
||||
* window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* return () => {
|
||||
* window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* };
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export const flushPendingEventsBeforeUnload = () => {
|
||||
// 取消已调度的 idle callback
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
|
||||
// 立即刷新所有待发送事件
|
||||
flushPendingEvents();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
'%c🔄 [trackingHelpers] Flushed pending events before unload',
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前待发送事件数量(调试用)
|
||||
*
|
||||
* @returns {number} 待发送事件数量
|
||||
*/
|
||||
export const getPendingEventsCount = () => {
|
||||
return pendingEvents.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空待发送事件队列(测试用)
|
||||
*/
|
||||
export const clearPendingEvents = () => {
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
pendingEvents = [];
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
trackEventIdle,
|
||||
trackEventImmediate,
|
||||
smartTrack,
|
||||
flushPendingEventsBeforeUnload,
|
||||
getPendingEventsCount,
|
||||
clearPendingEvents,
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
// 扩展 Day.js 插件
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
@@ -12,7 +18,7 @@ import moment from 'moment';
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
const startMoment = dayjs(startTime);
|
||||
const endMoment = dayjs(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
const eventTime = dayjs(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
const startStr = dayjs(startTime).format('MM-DD HH:mm');
|
||||
const endStr = dayjs(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
const day = dayjs(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
let prevDay = dayjs(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
|
||||
@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const modeData = useMemo(
|
||||
() => currentMode === 'four-row' ? fourRowData : verticalData,
|
||||
[currentMode, fourRowData, verticalData]
|
||||
);
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
// 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
|
||||
const allCachedEventsByPage = useMemo(
|
||||
() => currentMode === 'vertical' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
const allCachedEvents = useMemo(
|
||||
() => currentMode === 'four-row' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
{dayjs(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -137,7 +137,7 @@ const CompactEventCard = ({
|
||||
<Text>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
<Text>•</Text>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = React.memo(({
|
||||
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
|
||||
*/
|
||||
const getTradingPeriod = (timestamp) => {
|
||||
const eventTime = moment(timestamp);
|
||||
const eventTime = dayjs(timestamp);
|
||||
const hour = eventTime.hour();
|
||||
const minute = eventTime.minute();
|
||||
const timeInMinutes = hour * 60 + minute;
|
||||
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = React.memo(({
|
||||
color={timeLabelStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{periodLabel && (
|
||||
<>
|
||||
{' • '}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventTimeline.js
|
||||
import React from 'react';
|
||||
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 事件时间轴组件
|
||||
@@ -56,7 +56,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{moment(createdAt).format('MM-DD')}
|
||||
{dayjs(createdAt).format('MM-DD')}
|
||||
</Text>
|
||||
{/* 时间 HH:mm */}
|
||||
<Text
|
||||
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
lineHeight="1.2"
|
||||
mt={0.5}
|
||||
>
|
||||
{moment(createdAt).format('HH:mm')}
|
||||
{dayjs(createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 时间轴竖线 */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -143,7 +143,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
{dayjs(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
@@ -234,7 +234,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<strong>{comment.author?.username || 'Anonymous'}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
|
||||
{moment(comment.created_at).format('MM-DD HH:mm')}
|
||||
{dayjs(comment.created_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
<span className="time-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
<span className="time-hour">{dayjs(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(moment());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{moment(time).format('HH:mm')}</Text>
|
||||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
@@ -160,7 +160,7 @@ const MarketReviewCard = forwardRef(({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { Drawer, Spin, Button, Alert } from 'antd';
|
||||
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { Tabs as AntdTabs } from 'antd';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Services and Utils
|
||||
import { eventService } from '../../../services/eventService';
|
||||
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
if (fixedCharts.length === 0) return null;
|
||||
|
||||
const formattedEventTime = event?.start_time
|
||||
? moment(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
: undefined;
|
||||
|
||||
return fixedCharts.map(({ stock }, index) => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
|
||||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||||
try {
|
||||
const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const parseMinuteTime = (timeStr) => {
|
||||
const [h, m] = String(timeStr).split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Button } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -31,7 +31,7 @@ const StockTable = ({
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 切换行展开状态
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// src/views/Community/hooks/useCommunityEvents.js
|
||||
// 新闻催化分析页面事件追踪 Hook
|
||||
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { smartTrack } from '@/utils/trackingHelpers';
|
||||
|
||||
/**
|
||||
* 新闻催化分析(Community)事件追踪 Hook
|
||||
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
|
||||
export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
// 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useCommunityEvents', '📰 Community Page Viewed');
|
||||
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} params.industryFilter - 行业筛选
|
||||
*/
|
||||
const trackNewsListViewed = useCallback((params = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
total_count: params.totalCount || 0,
|
||||
sort_by: params.sortBy || 'new',
|
||||
importance_filter: params.importance || 'all',
|
||||
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} filters.industryCode - 行业代码
|
||||
*/
|
||||
const trackNewsFilterApplied = useCallback((filters = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
importance: filters.importance || 'all',
|
||||
date_range: filters.dateRange || 'all',
|
||||
industry_classification: filters.industryClassification || 'all',
|
||||
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_SORTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
|
||||
sort_by: sortBy,
|
||||
previous_sort: previousSort,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const trackNewsSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
// 如果没有搜索结果,额外追踪(高优先级,立即发送)
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'news_related_stocks',
|
||||
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'news_related_concepts',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
} from '@/store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useNotification } from '@/contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
|
||||
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
@@ -96,6 +97,15 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !loading) {
|
||||
|
||||
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { DateClickArg } from '@fullcalendar/interaction';
|
||||
import { EventClickArg } from '@fullcalendar/common';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 新事件表单数据类型
|
||||
*/
|
||||
interface NewEventForm {
|
||||
title: string;
|
||||
description: string;
|
||||
type: EventType;
|
||||
importance: number;
|
||||
stocks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar 事件类型
|
||||
*/
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
date: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
extendedProps: InvestmentEvent & {
|
||||
isSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarPanel 组件
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setActiveTab,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [newEvent, setNewEvent] = useState<NewEventForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 转换数据为 FullCalendar 格式
|
||||
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info: DateClickArg): void => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info: EventClickArg): void => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('CalendarPanel', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadAllData();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
const handleDeleteEvent = async (eventId: number): Promise<void> => {
|
||||
if (!eventId) {
|
||||
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('CalendarPanel', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到计划或复盘标签页
|
||||
const handleViewDetails = (event: InvestmentEvent): void => {
|
||||
if (event.type === 'plan') {
|
||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
||||
} else if (event.type === 'review') {
|
||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
if (!selectedDate) setSelectedDate(dayjs());
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.source === 'future' ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : event.type === 'plan' ? (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{event.importance && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.importance}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack>
|
||||
{!event.source || event.source === 'user' ? (
|
||||
<>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleViewDetails(event)}
|
||||
aria-label="查看详情"
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
aria-label="删除事件"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{event.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="review">投资复盘</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
|
||||
@@ -66,13 +66,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import '../components/InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建 Context 用于跨标签页共享数据
|
||||
const PlanningDataContext = createContext();
|
||||
@@ -232,11 +232,11 @@ function CalendarPanel() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
moment(event.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -245,11 +245,11 @@ function CalendarPanel() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
moment(ev.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -262,7 +262,7 @@ function CalendarPanel() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -368,7 +368,7 @@ function CalendarPanel() {
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
@@ -619,7 +619,7 @@ function PlansPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -638,13 +638,13 @@ function PlansPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -795,7 +795,7 @@ function PlansPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - PlansPanel (计划面板,懒加载)
|
||||
* - ReviewsPanel (复盘面板,懒加载)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const PlansPanel = lazy(() =>
|
||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
||||
);
|
||||
const ReviewsPanel = lazy(() =>
|
||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
* 面板加载占位符
|
||||
*/
|
||||
const PanelLoadingFallback: React.FC = () => (
|
||||
<Center py={12}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
toast,
|
||||
bgColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">投资规划中心</Heading>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiCalendar} mr={2} />
|
||||
日历视图
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 日历视图面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<PlansPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<ReviewsPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentPlanningCenter;
|
||||
@@ -60,12 +60,12 @@ import {
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -83,7 +83,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -134,12 +134,12 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
@@ -291,7 +291,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function MyFutureEvents({ limit = 5 }) {
|
||||
const [futureEvents, setFutureEvents] = useState([]);
|
||||
@@ -51,7 +51,7 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
if (response.success) {
|
||||
// 按时间排序,最近的在前
|
||||
const sortedEvents = (response.data || []).sort((a, b) =>
|
||||
moment(a.calendar_time).valueOf() - moment(b.calendar_time).valueOf()
|
||||
dayjs(a.calendar_time).valueOf() - dayjs(b.calendar_time).valueOf()
|
||||
);
|
||||
setFutureEvents(sortedEvents);
|
||||
logger.debug('MyFutureEvents', '未来事件加载成功', {
|
||||
@@ -98,8 +98,8 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
|
||||
// 格式化时间
|
||||
const formatEventTime = (time) => {
|
||||
const eventTime = moment(time);
|
||||
const now = moment();
|
||||
const eventTime = dayjs(time);
|
||||
const now = dayjs();
|
||||
const daysDiff = eventTime.diff(now, 'days');
|
||||
|
||||
if (daysDiff === 0) {
|
||||
|
||||
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter Context
|
||||
* 用于在日历、计划、复盘三个面板间共享数据和状态
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import type { PlanningContextValue } from '@/types';
|
||||
|
||||
/**
|
||||
* Planning Data Context
|
||||
* 提供投资规划数据和操作方法
|
||||
*/
|
||||
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* PlanningDataProvider Props
|
||||
*/
|
||||
interface PlanningDataProviderProps {
|
||||
/** Context 值 */
|
||||
value: PlanningContextValue;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanningDataProvider 组件
|
||||
* 包裹需要访问投资规划数据的组件
|
||||
*/
|
||||
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
|
||||
return (
|
||||
<PlanningDataContext.Provider value={value}>
|
||||
{children}
|
||||
</PlanningDataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* usePlanningData Hook
|
||||
* 在子组件中访问投资规划数据
|
||||
*
|
||||
* @throws {Error} 如果在 PlanningDataProvider 外部调用
|
||||
* @returns {PlanningContextValue} Context 值
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function CalendarPanel() {
|
||||
* const { allEvents, loading, toast } = usePlanningData();
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePlanningData = (): PlanningContextValue => {
|
||||
const context = useContext(PlanningDataContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* PlansPanel - 投资计划列表面板组件
|
||||
* 显示、编辑和管理投资计划
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiTarget,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlansPanel 组件
|
||||
* 计划列表面板,显示所有投资计划
|
||||
*/
|
||||
export const PlansPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选计划列表(排除系统事件)
|
||||
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'plan',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑计划"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除计划"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:布局新能源板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* ReviewsPanel - 投资复盘列表面板组件
|
||||
* 显示、编辑和管理投资复盘
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewsPanel 组件
|
||||
* 复盘列表面板,显示所有投资复盘
|
||||
*/
|
||||
export const ReviewsPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选复盘列表(排除系统事件)
|
||||
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'review',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiFileText} color="green.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑复盘"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除复盘"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="green.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资复盘</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资复盘
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:本周操作复盘"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细记录您的投资复盘..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Divider
|
||||
} from '@chakra-ui/react';
|
||||
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||
@@ -326,7 +326,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof tradeDate === 'string') {
|
||||
formattedTradeDate = tradeDate;
|
||||
} else if (tradeDate instanceof Date) {
|
||||
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(tradeDate).format('YYYY-MM-DD');
|
||||
} else if (moment.isMoment(tradeDate)) {
|
||||
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
tradeDate,
|
||||
tradeDateType: typeof tradeDate
|
||||
});
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
@@ -414,18 +414,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
|
||||
// 检查是否是Date对象
|
||||
if (eventTime instanceof Date) {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'string') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'number') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '未知的事件时间格式', {
|
||||
eventTime,
|
||||
eventTimeType: typeof eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
// 确保moment对象有效
|
||||
@@ -434,7 +434,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
formattedDate = eventMoment.format('YYYY-MM-DD');
|
||||
@@ -448,7 +448,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof nextTradingDay === 'string') {
|
||||
formattedDate = nextTradingDay;
|
||||
} else if (nextTradingDay instanceof Date) {
|
||||
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(nextTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
|
||||
nextTradingDay,
|
||||
@@ -476,16 +476,16 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof currentTradingDay === 'string') {
|
||||
formattedDate = currentTradingDay;
|
||||
} else if (currentTradingDay instanceof Date) {
|
||||
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
|
||||
currentTradingDay,
|
||||
eventId
|
||||
});
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} else {
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,9 +558,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
<FaCalendarAlt color={textColor} />
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
涨跌幅数据日期:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs">
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -195,9 +195,12 @@ const EnhancedCalendar = ({
|
||||
onClick={() => onDateChange(date)}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text
|
||||
fontSize={compact ? 'md' : 'lg'}
|
||||
fontSize={compact ? 'lg' : 'xl'}
|
||||
fontWeight={isToday || isSelected ? 'bold' : 'normal'}
|
||||
color={isSelected ? 'blue.600' : 'gray.700'}
|
||||
>
|
||||
@@ -206,13 +209,13 @@ const EnhancedCalendar = ({
|
||||
{hasData && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="2px"
|
||||
right="2px"
|
||||
top="4px"
|
||||
right="4px"
|
||||
size={compact ? 'sm' : 'md'}
|
||||
colorScheme={getDateBadgeColor(dateData.count)}
|
||||
fontSize={compact ? '10px' : '11px'}
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
px={compact ? 1 : 2}
|
||||
minW={compact ? '22px' : '28px'}
|
||||
minW={compact ? '20px' : '24px'}
|
||||
borderRadius="full"
|
||||
>
|
||||
{dateData.count}
|
||||
@@ -221,7 +224,7 @@ const EnhancedCalendar = ({
|
||||
{isToday && (
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom="2px"
|
||||
bottom="4px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
|
||||
@@ -444,7 +444,6 @@ export default function LimitAnalyse() {
|
||||
borderColor="whiteAlpha.300"
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
w="full"
|
||||
minH="420px"
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<EnhancedCalendar
|
||||
@@ -453,8 +452,9 @@ export default function LimitAnalyse() {
|
||||
availableDates={availableDates}
|
||||
compact
|
||||
hideSelectionInfo
|
||||
hideLegend
|
||||
width="100%"
|
||||
cellHeight={10}
|
||||
cellHeight={16}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user