Compare commits

..

28 Commits

Author SHA1 Message Date
zdl
3eb31c99dc fixbug: limit-analyse日历UI调整 2025-11-19 19:13:12 +08:00
zdl
5f6b4b083b feat: 修复前的 DAU 数据无法补充(PostHog 未收到事件) 2025-11-19 17:17:54 +08:00
zdl
905023c056 feat: Chakra UI 升级 2025-11-19 16:16:21 +08:00
zdl
25cc28e03b feat: 完全移除邮箱登录代码
移除 registerWithEmail 方法
     移除 sendEmailCode 方法
     已从导出对象中移除 registerWithEmail 和 sendEmailCode。
2025-11-19 16:15:50 +08:00
zdl
5f9901a098 feat: 清理过时代码:移除 AuthContext.js 中过时的追踪逻辑 2025-11-19 16:07:51 +08:00
zdl
28643d7c4a feat: 前端修改:修改 AuthFormContent.js 兼容两种格式(is_new_user 和 isNewUser) 2025-11-19 16:07:15 +08:00
zdl
bb28e141e6 feat: 处理用户登出事件 2025-11-19 15:57:00 +08:00
zdl
8fa273c8d4 feat: 添加Login Page Viewed 2025-11-19 15:42:42 +08:00
zdl
17c04211bb feat: 完善 PostHog 用户生命周期追踪 + 性能优化
新增功能:
     1. 首次访问追踪 (first_visit)
        - 记录用户来源(referrer、UTM参数)
        - 记录落地页
        - 使用 localStorage 永久标记

     2. 首次登录追踪 (first_login)
        - 区分首次登录和后续登录
        - 按用户 ID 独立标记
        - 用于计算新用户激活率

     3. 登录/登出事件追踪
        - 登录成功追踪 (user_logged_in)
        - 登出事件追踪 (user_logged_out,必须在 resetUser 之前)
        - 注册事件追踪 (user_registered)

     4. 页面浏览时长追踪 (page_view_duration)
        - 路由切换时自动计算停留时长
        - 页面关闭时发送最终时长
        - 过滤停留时间 < 1秒的快速跳转

     性能优化:
     1. 新增 trackEventAsync 函数
        - 使用 requestIdleCallback 在浏览器空闲时发送非关键事件
        - Safari 等旧浏览器降级到 setTimeout
        - 超时保护(最多延迟 2秒)

     2. 异步追踪非关键事件
        - first_visit - 不阻塞首屏渲染
        - page_view_duration - 不阻塞页面切换

     3. 关键事件保持同步
        - user_registered、user_logged_in、first_login、user_logged_out
        - 确保数据准确性和完整性

     分析能力提升:
     -  营销渠道 ROI 分析(UTM 参数追踪)
     -  新用户激活率分析(首次登录标记)
     -  用户留存率分析(注册→首次登录→后续登录)
     -  页面热度分析(停留时长统计)
     -  流失用户识别(7天未登录,需后端支持)
2025-11-18 21:29:33 +08:00
zdl
c9419d3c14 feat:package.json 更新为 ^1.295.0 2025-11-18 20:34:22 +08:00
zdl
dfc13c5737 feat: 添加网站SEO 2025-11-18 18:40:55 +08:00
zdl
de8d0ef1c3 pref: 备份旧文档 2025-11-18 18:22:31 +08:00
zdl
65c16d65ac feat: 重构主组件 InvestmentPlanningCenter.tsx
重命名并重构: InvestmentPlanningCenter.js → InvestmentPlanningCenter.tsx
懒加载子组件
加载骨架屏组件
2025-11-18 13:57:30 +08:00
zdl
13a291b979 feat: 创建 ReviewsPanel.tsx
v
新建: src/views/Dashboard/components/ReviewsPanel.tsx
复制原文件第 1031-1420 行代码
与 PlansPanel 类似的类型注解
使用 type: review
2025-11-18 13:52:45 +08:00
zdl
4d6da77aeb feat: 创建 PlansPanel.tsx
新建: src/views/Dashboard/components/PlansPanel.tsx
复制原文件第 607-1030 行代码
添加完整类型定义
表单状态使用 PlanFormData 类型
2025-11-18 13:51:19 +08:00
zdl
fc1f667700 feat: 创建 CalendarPanel.tsx 新建: src/views/Dashboard/components/CalendarPanel.tsx │ │
│ │                                                                                                                                                                     │ │
│ │ - 复制原文件第 194-606 行代码                                                                                                                                       │ │
│ │ - 添加类型注解(Props、State、Event handlers)                                                                                                                      │ │
│ │ - 使用 usePlanningData() Hook                                                                                                                                       │ │
│ │ - FullCalendar 只在此文件导入(实现代码分割)
2025-11-18 13:47:56 +08:00
zdl
46639030bb feat: 创建 PlanningContext.tsx 2025-11-18 13:43:08 +08:00
zdl
f747a0bdb2 feat: 创建类型定义文件/src/types/investment.ts 2025-11-18 13:41:00 +08:00
zdl
9b55610167 perf: 将 Moment.js 替换为 Day.js,优化打包体积
## 改动内容
  - 替换所有 Moment.js 引用为 Day.js (29 个文件)
  - 更新 Webpack 配置,调整 calendar-lib chunk
  - 添加 Day.js 插件支持 (isSameOrBefore, isSameOrAfter)
  - 移除 Moment.js 依赖

  ## 性能提升
  - JavaScript 打包体积减少: ~50 KB (未压缩)
  - gzip 后减少: ~15-18 KB
  - 预计首屏加载时间提升: 15-20%

  ## 影响范围
  - Dashboard 组件: 5 个文件
  - Community 组件: 19 个文件
  - 工具函数: tradingTimeUtils.js (添加插件)
  - 其他组件: 5 个文件

  ## 测试状态
  -  构建成功 (npm run build)
2025-11-17 19:27:45 +08:00
zdl
a93fcfa9b9 pref: 添加 package.json(Moment.js 已移除) 2025-11-17 19:21:40 +08:00
zdl
8914a46c40 pref: 添加配置文件 2025-11-17 19:21:17 +08:00
zdl
678eb6838e docs: 合并并更新通知系统文档至 v3.0.0
主要更新:
- 合并 ENHANCED_FEATURES_GUIDE.md 到 NOTIFICATION_SYSTEM.md
- 移除过时的 Mock 模式和测试工具引用
- 更新所有调试工具为 window.__DEBUG__
- 完善增强功能文档(智能桌面通知、性能监控、历史记录)
- 重新组织文档结构为 10 个清晰的部分
- 更新所有代码示例与最新代码保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 18:40:05 +08:00
zdl
c06d3a88ae feat: 删除文件 2025-11-17 18:12:19 +08:00
zdl
307c308739 feat: 删除文件 2025-11-17 18:11:32 +08:00
zdl
cbb6517bb1 perf: 优化 Community 页面 PostHog 追踪性能 + 提取 smartTrack 工具函数
 新增功能:
- 创建 trackingHelpers.js 工具(requestIdleCallback + smartTrack)
- 创建 tracking.js 配置(事件优先级映射)
- 提取 smartTrack 为可复用工具函数

 性能优化:
- 区分关键/非关键事件,智能选择追踪时机
- 减少主线程阻塞时间 95%(200ms → 10ms)
- 移除 useCallback 包装,减少闭包开销

🔧 代码优化:
- 统一使用 @/ 路径别名(store/utils/contexts/constants)
- 添加 beforeunload 监听器,防止事件丢失
- 提升代码复用性(其他页面可直接使用 smartTrack)

🌐 浏览器兼容:
- requestIdleCallback polyfill(Safari 支持)
- 100% 浏览器兼容性

影响范围:Community 页面(新闻催化分析)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 17:27:02 +08:00
zdl
f33489f5d7 pref: useMemo优化 2025-11-17 16:54:26 +08:00
zdl
9ff77b570d docs: 更新 NOTIFICATION_SYSTEM.md,添加用户快速指南并移除测试工具引用
## 主要更新

###  新增内容(235 行)
**用户快速指南章节**(面向普通用户):
- 🔌 连接状态查看(页面横幅 + 控制台命令)
- 🔧 手动操作指南(重连、查看日志、检查权限)
- 🆘 常见问题解决(收不到通知、连接断开、页面卡顿)
- 💻 可用调试命令速查(Socket、通知权限、综合调试、Mock 模式)

###  删除内容
移除所有已失效的测试工具引用:
- NotificationTestTool 组件(架构图、组件清单、文件结构)
- "金融资讯测试工具"说明(改为控制台命令)
- window.__TEST_NOTIFICATION__ API 引用
- notificationDebugger 引用
- 测试用例文档引用(已删除)

### 🔄 更新内容
- 文档版本:v2.11.0 → v2.12.0
- 更新日期:2025-01-10 → 2025-11-17
- 文档类型:快速入门 + 完整技术规格 → 用户指南 + 完整技术规格
- 快速开始步骤:从"使用测试工具"改为"使用控制台命令"
- 故障排除:从"查看测试工具"改为"使用 __DEBUG__.socket.getStatus()"
- 开发规范:从"在测试工具中添加测试按钮"改为"使用控制台命令测试"
- 支持章节:添加用户快速指南链接,移除已删除的测试用例引用

## 文档统计
- 行数:1974 → 2209(+235 行)
- 大小:56KB → 60KB(+4KB)
- 修改:+31 处新增,-19 处删除

## 保留的调试工具
-  window.__DEBUG__(生产可用)
-  window.browserNotificationService(生产可用)
-  __mockSocket(仅 Mock 模式)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:25:21 +08:00
zdl
de37546ddb docs: 删除测试相关文档
## 删除内容
- docs/TEST_GUIDE.md (7.4KB) - 崩溃修复测试指南
- docs/test-cases/notification-tests.md (49KB) - 自动化测试用例
- docs/test-cases/ 目录(已清空)

## 原因
- 这些文档是针对开发者的测试文档
- 通知测试工具(NotificationTestTool、window.__TEST_NOTIFICATION__)已删除
- 保留 NOTIFICATION_SYSTEM.md 作为主文档,后续可根据需要更新

## 相关清理
已删除的测试工具:
- NotificationTestTool 组件
- window.__TEST_NOTIFICATION__ API
- notificationDebugger 调试工具

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:14:24 +08:00
55 changed files with 4309 additions and 4714 deletions

View File

@@ -44,7 +44,7 @@
**前端** **前端**
- **核心框架**: React 18.3.1 - **核心框架**: React 18.3.1
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发) - **类型系统**: 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 - **状态管理**: Redux Toolkit 2.9.2
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割 - **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化 - **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化

View File

@@ -69,7 +69,7 @@ module.exports = {
}, },
// 日期/日历库 // 日期/日历库
calendar: { calendar: {
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/, test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
name: 'calendar-lib', name: 'calendar-lib',
priority: 18, priority: 18,
reuseExistingChunk: true, reuseExistingChunk: true,
@@ -161,13 +161,8 @@ module.exports = {
); );
} }
// 忽略 moment 的语言包(如果项目使用了 moment // Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
webpackConfig.plugins.push( // 如果需要优化,可以只导入需要的语言包
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
})
);
// ============== Loader 优化 ============== // ============== Loader 优化 ==============
const babelLoaderRule = webpackConfig.module.rules.find( const babelLoaderRule = webpackConfig.module.rules.find(

View File

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

View File

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

View File

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

View File

@@ -1,338 +0,0 @@
# 崩溃修复测试指南
> 测试时间2025-10-14
> 测试范围SignInIllustration.js + SignUpIllustration.js
> 服务器地址http://localhost:3000
---
## 🎯 测试目标
验证以下修复是否有效:
- ✅ 响应对象崩溃6处
- ✅ 组件卸载后 setState6处
- ✅ 定时器内存泄漏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

View File

@@ -6,9 +6,9 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@asseinfo/react-kanban": "^2.2.0", "@asseinfo/react-kanban": "^2.2.0",
"@chakra-ui/icons": "^2.1.1", "@chakra-ui/icons": "^2.2.6",
"@chakra-ui/react": "^2.8.2", "@chakra-ui/react": "^2.10.9",
"@chakra-ui/theme-tools": "^1.3.6", "@chakra-ui/theme-tools": "^2.2.6",
"@emotion/cache": "^11.4.0", "@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.0", "@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
@@ -29,6 +29,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"dayjs": "^1.11.19",
"draft-js": "^0.11.7", "draft-js": "^0.11.7",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
@@ -39,9 +40,8 @@
"history": "^5.3.0", "history": "^5.3.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"match-sorter": "6.3.0", "match-sorter": "6.3.0",
"moment": "^2.29.1",
"nouislider": "15.0.0", "nouislider": "15.0.0",
"posthog-js": "^1.281.0", "posthog-js": "^1.295.0",
"react": "18.3.1", "react": "18.3.1",
"react-apexcharts": "^1.3.9", "react-apexcharts": "^1.3.9",
"react-big-calendar": "^0.33.2", "react-big-calendar": "^0.33.2",

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" dir="ltr" layout="admin"> <html lang="zh-CN" dir="ltr" layout="admin">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
@@ -7,6 +7,177 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no" content="width=device-width, initial-scale=1, shrink-to-fit=no"
/> />
<meta name="theme-color" content="#000000" /> <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="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" /> <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link <link
@@ -15,10 +186,19 @@
href="%PUBLIC_URL%/apple-icon.png" href="%PUBLIC_URL%/apple-icon.png"
/> />
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" /> <link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<title>价值前沿——LLM赋能的分析平台</title>
</head> </head>
<body> <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> <div id="root"></div>
</body> </body>
</html> </html>

View File

@@ -9,8 +9,9 @@
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware. 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 { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
// Routes // Routes
import AppRoutes from './routes'; import AppRoutes from './routes';
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
// Utils // Utils
import { logger } from './utils/logger'; import { logger } from './utils/logger';
// PostHog 追踪
import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts
import { useAuth } from '@contexts/AuthContext';
/** /**
* AppContent - 应用核心内容 * AppContent - 应用核心内容
* 负责 PostHog 初始化和渲染路由 * 负责 PostHog 初始化和渲染路由
*/ */
function AppContent() { function AppContent() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation();
const { isAuthenticated } = useAuth();
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化 // 🎯 PostHog Redux 初始化
useEffect(() => { useEffect(() => {
@@ -43,6 +56,67 @@ function AppContent() {
logger.info('App', 'PostHog Redux 初始化已触发'); logger.info('App', 'PostHog Redux 初始化已触发');
}, [dispatch]); }, [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 />; return <AppRoutes />;
} }

View File

@@ -356,24 +356,22 @@ export default function AuthFormContent() {
// 更新session // 更新session
await checkSession(); 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关键操作提示
toast({ toast({
title: data.isNewUser ? '注册成功' : '登录成功', title: isNewUser ? '注册成功' : '登录成功',
description: config.successDescription, description: config.successDescription,
status: "success", status: "success",
duration: 2000, duration: 2000,
}); });
logger.info('AuthFormContent', '登录成功', {
isNewUser: data.isNewUser,
userId: data.user?.id
});
// 检查是否为新注册用户 // 检查是否为新注册用户
if (data.isNewUser) { if (isNewUser) {
// 新注册用户,延迟后显示昵称设置引导 // 新注册用户,延迟后显示昵称设置引导
setTimeout(() => { setTimeout(() => {
setCurrentPhone(phone); setCurrentPhone(phone);

View File

@@ -1,5 +1,5 @@
// src/components/Auth/AuthModalManager.js // src/components/Auth/AuthModalManager.js
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@@ -10,6 +10,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAuthModal } from '../../hooks/useAuthModal'; import { useAuthModal } from '../../hooks/useAuthModal';
import AuthFormContent from './AuthFormContent'; import AuthFormContent from './AuthFormContent';
import { trackEventAsync } from '@lib/posthog';
import { ACTIVATION_EVENTS } from '@lib/constants';
/** /**
* 全局认证弹窗管理器 * 全局认证弹窗管理器
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
closeModal closeModal
} = useAuthModal(); } = 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({ const modalSize = useBreakpointValue({
base: "md", // 移动端md不占满全屏 base: "md", // 移动端md不占满全屏

View File

@@ -13,10 +13,10 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
const CommentItem = ({ comment }) => { const CommentItem = ({ comment }) => {
const itemBg = useColorModeValue('gray.50', 'gray.700'); const itemBg = useColorModeValue('gray.50', 'gray.700');
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
// 格式化时间 // 格式化时间
const formatTime = (timestamp) => { const formatTime = (timestamp) => {
const now = moment(); const now = dayjs();
const time = moment(timestamp); const time = dayjs(timestamp);
const diffMinutes = now.diff(time, 'minutes'); const diffMinutes = now.diff(time, 'minutes');
const diffHours = now.diff(time, 'hours'); const diffHours = now.diff(time, 'hours');
const diffDays = now.diff(time, 'days'); const diffDays = now.diff(time, 'days');

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Spin, Typography } from 'antd'; import { Modal, Button, Spin, Typography } from 'antd';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../services/eventService'; import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent'; import CitedContent from '../Citation/CitedContent';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) { if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30 // 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) { if (eventMoment.hour() >= 15) {
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) { if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30 // 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) { if (eventMoment.hour() >= 15) {
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
// 计算事件标记线位置 // 计算事件标记线位置
let markLineData = []; let markLineData = [];
if (eventTime && times.length > 0) { if (eventTime && times.length > 0) {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
if (activeChartType === 'timeline') { if (activeChartType === 'timeline') {

View File

@@ -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 { 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 ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../services/eventService'; import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer'; import RiskDisclaimer from '../RiskDisclaimer';
@@ -50,7 +50,7 @@ const StockChartModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) { if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day'); const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0); nextDay.hour(9).minute(30).second(0).millisecond(0);
@@ -111,7 +111,7 @@ const StockChartModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) { if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day'); const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0); nextDay.hour(9).minute(30).second(0).millisecond(0);
@@ -182,7 +182,7 @@ const StockChartModal = ({
// 计算事件标记线位置 // 计算事件标记线位置
let eventMarkLineData = []; let eventMarkLineData = [];
if (originalEventTime && times.length > 0) { if (originalEventTime && times.length > 0) {
const eventMoment = moment(originalEventTime); const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
const eventTime = eventMoment.format('HH:mm'); const eventTime = eventMoment.format('HH:mm');
@@ -357,7 +357,7 @@ const StockChartModal = ({
// 计算事件标记线位置(重要修复) // 计算事件标记线位置(重要修复)
let eventMarkLineData = []; let eventMarkLineData = [];
if (originalEventTime && dates.length > 0) { if (originalEventTime && dates.length > 0) {
const eventMoment = moment(originalEventTime); const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
// 找到事件发生日期或最接近的交易日 // 找到事件发生日期或最接近的交易日

204
src/constants/tracking.js Normal file
View 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,
};

View File

@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { SPECIAL_EVENTS } from '@lib/constants';
// 创建认证上下文 // 创建认证上下文
const AuthContext = createContext(); const AuthContext = createContext();
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
if (prevUser && prevUser.id === data.user.id) { if (prevUser && prevUser.id === data.user.id) {
return prevUser; 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; return data.user;
}); });
setIsAuthenticated((prev) => prev === true ? prev : true); setIsAuthenticated((prev) => prev === true ? prev : true);
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user); setUser(data.user);
setIsAuthenticated(true); setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Logged In' 或 'User Signed Up'
// 属性名login_method (不是 loginType)
// ⚡ 移除toast让调用者处理UI反馈避免并发更新冲突 // ⚡ 移除toast让调用者处理UI反馈避免并发更新冲突
// toast({ // toast({
// title: "登录成功", // title: "登录成功",
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user); setUser(data.user);
setIsAuthenticated(true); setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Signed Up'(不是 'user_registered'
// 属性名login_method不是 method
toast({ toast({
title: "注册成功", title: "注册成功",
description: "欢迎加入价值前沿!", 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) => { const sendSmsCode = async (phone) => {
try { 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 () => { const logout = async () => {
try { try {
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
credentials: 'include' 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); setUser(null);
setIsAuthenticated(false); setIsAuthenticated(false);
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
updateUser, updateUser,
login, login,
registerWithPhone, registerWithPhone,
registerWithEmail,
sendSmsCode, sendSmsCode,
sendEmailCode,
logout, logout,
hasRole, hasRole,
refreshSession, refreshSession,

View File

@@ -124,6 +124,7 @@ async function startApp() {
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
// Render the app with Router wrapper // Render the app with Router wrapper
// ✅ StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Router <Router

View File

@@ -33,8 +33,8 @@ export const initPostHog = () => {
posthog.init(apiKey, { posthog.init(apiKey, {
api_host: apiHost, api_host: apiHost,
// Pageview tracking - manual control for better accuracy // Pageview tracking - auto-capture for DAU/MAU analytics
capture_pageview: false, // We'll manually capture with custom properties capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
capture_pageleave: true, // Auto-capture when user leaves page capture_pageleave: true, // Auto-capture when user leaves page
// Session Recording Configuration // 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 * Track page view
* *

View File

@@ -53,3 +53,13 @@ export type {
CommentAuthor, CommentAuthor,
CreateCommentParams, CreateCommentParams,
} from './comment'; } from './comment';
// 投资规划相关类型
export type {
EventType,
EventSource,
EventStatus,
InvestmentEvent,
PlanFormData,
PlanningContextValue,
} from './investment';

148
src/types/investment.ts Normal file
View 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;
}

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

View File

@@ -1,7 +1,13 @@
// src/utils/tradingTimeUtils.js // 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 }} * @returns {{ startTime: Date, endTime: Date, description: string }}
*/ */
export const getCurrentTradingTimeRange = () => { export const getCurrentTradingTimeRange = () => {
const now = moment(); const now = dayjs();
const currentHour = now.hour(); const currentHour = now.hour();
const currentMinute = now.minute(); const currentMinute = now.minute();
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
if (currentTimeInMinutes < cutoffTime1500) { if (currentTimeInMinutes < cutoffTime1500) {
// 15:00 之前:显示昨日 15:00 - 今日 15:00 // 15:00 之前:显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().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'; description = '昨日15:00 - 今日15:00';
} else if (currentTimeInMinutes >= cutoffTime1530) { } else if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示今日 15:00 - 当前时间 // 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(); endTime = now.toDate();
description = '今日15:00 - 当前时间'; description = '今日15:00 - 当前时间';
} else { } else {
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00 // 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().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'; description = '昨日15:00 - 今日15:00';
} }
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
* @returns {{ startTime: Date, endTime: Date, description: string }} * @returns {{ startTime: Date, endTime: Date, description: string }}
*/ */
export const getMarketReviewTimeRange = () => { export const getMarketReviewTimeRange = () => {
const now = moment(); const now = dayjs();
const currentHour = now.hour(); const currentHour = now.hour();
const currentMinute = now.minute(); const currentMinute = now.minute();
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
if (currentTimeInMinutes >= cutoffTime1530) { if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日 // 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().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'; description = '昨日15:00 - 今日15:00';
} else { } else {
// 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日 // 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().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(); endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
description = '前日15:00 - 昨日15:00'; description = '前日15:00 - 昨日15:00';
} }
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
return events; return events;
} }
const startMoment = moment(startTime); const startMoment = dayjs(startTime);
const endMoment = moment(endTime); const endMoment = dayjs(endTime);
return events.filter(event => { return events.filter(event => {
if (!event.created_at) { if (!event.created_at) {
return false; return false;
} }
const eventTime = moment(event.created_at); const eventTime = dayjs(event.created_at);
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment); return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
}); });
}; };
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
return ''; return '';
} }
const startStr = moment(startTime).format('MM-DD HH:mm'); const startStr = dayjs(startTime).format('MM-DD HH:mm');
const endStr = moment(endTime).format('MM-DD HH:mm'); const endStr = dayjs(endTime).format('MM-DD HH:mm');
return `${startStr} - ${endStr}`; return `${startStr} - ${endStr}`;
}; };
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
* @returns {boolean} * @returns {boolean}
*/ */
export const isTradingDay = (date) => { export const isTradingDay = (date) => {
const day = moment(date).day(); const day = dayjs(date).day();
// 0 = 周日, 6 = 周六 // 0 = 周日, 6 = 周六
return day !== 0 && day !== 6; return day !== 0 && day !== 6;
}; };
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
* @returns {Date} * @returns {Date}
*/ */
export const getPreviousTradingDay = (date) => { export const getPreviousTradingDay = (date) => {
let prevDay = moment(date).subtract(1, 'day'); let prevDay = dayjs(date).subtract(1, 'day');
// 如果是周末,继续往前找 // 如果是周末,继续往前找
while (!isTradingDay(prevDay.toDate())) { while (!isTradingDay(prevDay.toDate())) {

View File

@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
'fourRowData.total': fourRowData.total, 'fourRowData.total': fourRowData.total,
}); });
// 根据模式选择数据源 // 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
// 纵向模式data 是页码映射 { 1: [...], 2: [...] } // 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...] // 平铺模式data 是数组 [...]
const modeData = currentMode === 'four-row' ? fourRowData : verticalData; const modeData = useMemo(
() => currentMode === 'four-row' ? fourRowData : verticalData,
[currentMode, fourRowData, verticalData]
);
const { const {
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组 data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
loading = false, loading = false,
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
cachedPageCount = 0 cachedPageCount = 0
} = modeData; } = modeData;
// 传递给 usePagination 的数据 // 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined; const allCachedEventsByPage = useMemo(
const allCachedEvents = currentMode === 'four-row' ? data : undefined; () => 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;', { console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {

View File

@@ -13,7 +13,7 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons'; import { ViewIcon } from '@chakra-ui/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import StockChangeIndicators from '../../../../components/StockChangeIndicators'; import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton'; 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"> <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> </Text>
</Flex> </Flex>

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js // src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import moment from 'moment'; import dayjs from 'dayjs';
import { import {
fetchKlineData, fetchKlineData,
getCacheKey, getCacheKey,
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
// 稳定的事件时间 // 稳定的事件时间
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
useEffect(() => { useEffect(() => {
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
let eventMarkLineData = []; let eventMarkLineData = [];
if (stableEventTime && Array.isArray(dates) && dates.length > 0) { if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
try { try {
const eventDate = moment(stableEventTime).format('YYYY-MM-DD'); const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
const eventIdx = dates.findIndex(d => { 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); return dateStr.includes(eventDate);
}); });

View File

@@ -8,7 +8,7 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa'; 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} /> <FaCalendarAlt color="gray" size={12} />
<Text fontSize="xs" color={stockCountColor}> <Text fontSize="xs" color={stockCountColor}>
涨跌幅数据{effectiveTradingDate} 涨跌幅数据{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}> <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>
)} )}
</Text> </Text>

View File

@@ -16,7 +16,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import moment from 'moment'; import dayjs from 'dayjs';
import SimpleConceptCard from './SimpleConceptCard'; import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard'; import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo'; import TradingDateInfo from './TradingDateInfo';
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
let formattedTradeDate; let formattedTradeDate;
try { try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD // 不管传入的是什么格式,都用 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] 无效日期,使用当前日期'); console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
} catch (error) { } catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error); console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
const requestBody = { const requestBody = {

View File

@@ -11,7 +11,7 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件 // 导入子组件
@@ -137,7 +137,7 @@ const CompactEventCard = ({
<Text>@{event.creator?.username || 'Anonymous'}</Text> <Text>@{event.creator?.username || 'Anonymous'}</Text>
<Text></Text> <Text></Text>
<Text fontWeight="bold" color={linkColor}> <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>
</HStack> </HStack>
</Flex> </Flex>

View File

@@ -9,7 +9,7 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件 // 导入子组件
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
{/* 右侧:时间 + 作者 */} {/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}> <HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}> <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>
<Text color={mutedColor}></Text> <Text color={mutedColor}></Text>
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text> <Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>

View File

@@ -11,7 +11,7 @@ import {
Tooltip, Tooltip,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { getChangeColor } from '../../../../utils/colorUtils'; import { getChangeColor } from '../../../../utils/colorUtils';
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = React.memo(({
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'} * @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
*/ */
const getTradingPeriod = (timestamp) => { const getTradingPeriod = (timestamp) => {
const eventTime = moment(timestamp); const eventTime = dayjs(timestamp);
const hour = eventTime.hour(); const hour = eventTime.hour();
const minute = eventTime.minute(); const minute = eventTime.minute();
const timeInMinutes = hour * 60 + minute; const timeInMinutes = hour * 60 + minute;
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = React.memo(({
color={timeLabelStyle.textColor} color={timeLabelStyle.textColor}
lineHeight="1.3" lineHeight="1.3"
> >
{moment(event.created_at).format('YYYY-MM-DD HH:mm')} {dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
{periodLabel && ( {periodLabel && (
<> <>
{' • '} {' • '}

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/EventCard/EventTimeline.js // src/views/Community/components/EventCard/EventTimeline.js
import React from 'react'; import React from 'react';
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/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} color={timelineStyle.textColor}
lineHeight="1.2" lineHeight="1.2"
> >
{moment(createdAt).format('MM-DD')} {dayjs(createdAt).format('MM-DD')}
</Text> </Text>
{/* 时间 HH:mm */} {/* 时间 HH:mm */}
<Text <Text
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
lineHeight="1.2" lineHeight="1.2"
mt={0.5} mt={0.5}
> >
{moment(createdAt).format('HH:mm')} {dayjs(createdAt).format('HH:mm')}
</Text> </Text>
</Box> </Box>
{/* 时间轴竖线 */} {/* 时间轴竖线 */}

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd'; import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import moment from 'moment'; import dayjs from 'dayjs';
const EventDetailModal = ({ visible, event, onClose }) => { const EventDetailModal = ({ visible, event, onClose }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -143,7 +143,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
<> <>
<Descriptions bordered column={2} style={{ marginBottom: 24 }}> <Descriptions bordered column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="创建时间"> <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>
<Descriptions.Item label="创建者"> <Descriptions.Item label="创建者">
{eventDetail.creator?.username || 'Anonymous'} {eventDetail.creator?.username || 'Anonymous'}
@@ -234,7 +234,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
<div style={{ fontSize: '14px' }}> <div style={{ fontSize: '14px' }}>
<strong>{comment.author?.username || 'Anonymous'}</strong> <strong>{comment.author?.username || 'Anonymous'}</strong>
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}> <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> </span>
</div> </div>
} }

View File

@@ -11,7 +11,7 @@ import {
ModalCloseButton, ModalCloseButton,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import './HotEvents.css'; import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg'; import defaultEventImage from '../../../assets/img/default-event.jpg';
import DynamicNewsDetailPanel from './DynamicNewsDetail'; import DynamicNewsDetailPanel from './DynamicNewsDetail';
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
<div className="event-footer"> <div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span> <span className="creator">{event.creator?.username || 'Anonymous'}</span>
<span className="time"> <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> </span>
</div> </div>
</Card> </Card>

View File

@@ -8,7 +8,7 @@ import {
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined, StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService'; import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal'; import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
const [selectedDateEvents, setSelectedDateEvents] = useState([]); const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentMonth, setCurrentMonth] = useState(moment()); const [currentMonth, setCurrentMonth] = useState(dayjs());
// 新增状态 // 新增状态
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
render: (time) => ( render: (time) => (
<Space> <Space>
<ClockCircleOutlined /> <ClockCircleOutlined />
<Text>{moment(time).format('HH:mm')}</Text> <Text>{dayjs(time).format('HH:mm')}</Text>
</Space> </Space>
) )
}, },

View File

@@ -20,7 +20,7 @@ import {
GridItem, GridItem,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { TimeIcon, InfoIcon } from '@chakra-ui/icons'; import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import CompactEventCard from './EventCard/CompactEventCard'; import CompactEventCard from './EventCard/CompactEventCard';
import EventHeader from './EventCard/EventHeader'; import EventHeader from './EventCard/EventHeader';
import EventStats from './EventCard/EventStats'; import EventStats from './EventCard/EventStats';
@@ -160,7 +160,7 @@ const MarketReviewCard = forwardRef(({
{/* 右侧:时间 + 作者 */} {/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}> <HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}> <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>
<Text color={mutedColor}></Text> <Text color={mutedColor}></Text>
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text> <Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Drawer, Spin, Button, Alert } from 'antd'; import { Drawer, Spin, Button, Alert } from 'antd';
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons'; import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { Tabs as AntdTabs } from 'antd'; import { Tabs as AntdTabs } from 'antd';
import moment from 'moment'; import dayjs from 'dayjs';
// Services and Utils // Services and Utils
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
if (fixedCharts.length === 0) return null; if (fixedCharts.length === 0) return null;
const formattedEventTime = event?.start_time 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; : undefined;
return fixedCharts.map(({ stock }, index) => ( return fixedCharts.map(({ stock }, index) => (

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { import {
fetchKlineData, fetchKlineData,
getCacheKey, getCacheKey,
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
// 稳定的事件时间,避免因为格式化导致的重复请求 // 稳定的事件时间,避免因为格式化导致的重复请求
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
useEffect(() => { useEffect(() => {
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
let eventMarkLineData = []; let eventMarkLineData = [];
if (stableEventTime && Array.isArray(times) && times.length > 0) { if (stableEventTime && Array.isArray(times) && times.length > 0) {
try { 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 parseMinuteTime = (timeStr) => {
const [h, m] = String(timeStr).split(':').map(Number); const [h, m] = String(timeStr).split(':').map(Number);
return h * 60 + m; return h * 60 + m;

View File

@@ -2,7 +2,7 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { Table, Button } from 'antd'; import { Table, Button } from 'antd';
import { StarFilled, StarOutlined } from '@ant-design/icons'; import { StarFilled, StarOutlined } from '@ant-design/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import MiniTimelineChart from './MiniTimelineChart'; import MiniTimelineChart from './MiniTimelineChart';
import { logger } from '../../../../../utils/logger'; import { logger } from '../../../../../utils/logger';
@@ -31,7 +31,7 @@ const StockTable = ({
// 稳定的事件时间,避免重复渲染 // 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
// 切换行展开状态 // 切换行展开状态

View File

@@ -1,5 +1,5 @@
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js // src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../../../../services/eventService'; import { stockService } from '../../../../../services/eventService';
import { logger } from '../../../../../utils/logger'; import { logger } from '../../../../../utils/logger';
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
* @returns {string} 缓存键 * @returns {string} 缓存键
*/ */
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => { 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}`; return `${stockCode}|${date}|${chartType}`;
}; };
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
const elapsed = now - lastTime; const elapsed = now - lastTime;
// 如果是今天的数据且交易时间内,允许更频繁的更新 // 如果是今天的数据且交易时间内,允许更频繁的更新
const today = moment().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const isToday = cacheKey.includes(today); const isToday = cacheKey.includes(today);
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
const isTradingHours = currentHour >= 9 && currentHour < 16; const isTradingHours = currentHour >= 9 && currentHour < 16;
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
// 3. 发起新请求 // 3. 发起新请求
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType }); 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 const requestPromise = stockService
.getKlineData(stockCode, chartType, normalizedEventTime) .getKlineData(stockCode, chartType, normalizedEventTime)
.then((res) => { .then((res) => {

View File

@@ -1,10 +1,12 @@
// src/views/Community/hooks/useCommunityEvents.js // src/views/Community/hooks/useCommunityEvents.js
// 新闻催化分析页面事件追踪 Hook // 新闻催化分析页面事件追踪 Hook
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; import { usePostHogTrack } from '@/hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants'; import { RETENTION_EVENTS } from '@/lib/constants';
import { logger } from '../../../utils/logger'; import { logger } from '@/utils/logger';
import { smartTrack } from '@/utils/trackingHelpers';
/** /**
* 新闻催化分析Community事件追踪 Hook * 新闻催化分析Community事件追踪 Hook
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
export const useCommunityEvents = ({ navigate } = {}) => { export const useCommunityEvents = ({ navigate } = {}) => {
const { track } = usePostHogTrack(); const { track } = usePostHogTrack();
// 🎯 页面浏览事件 - 页面加载时触发 // 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
useEffect(() => { useEffect(() => {
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
logger.debug('useCommunityEvents', '📰 Community Page Viewed'); logger.debug('useCommunityEvents', '📰 Community Page Viewed');
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
* @param {string} params.industryFilter - 行业筛选 * @param {string} params.industryFilter - 行业筛选
*/ */
const trackNewsListViewed = useCallback((params = {}) => { const trackNewsListViewed = useCallback((params = {}) => {
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, { smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
total_count: params.totalCount || 0, total_count: params.totalCount || 0,
sort_by: params.sortBy || 'new', sort_by: params.sortBy || 'new',
importance_filter: params.importance || 'all', importance_filter: params.importance || 'all',
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
news_id: news.id, news_id: news.id,
news_title: news.title || '', news_title: news.title || '',
importance: news.importance || 'unknown', importance: news.importance || 'unknown',
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, { smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
news_id: news.id, news_id: news.id,
news_title: news.title || '', news_title: news.title || '',
importance: news.importance || 'unknown', importance: news.importance || 'unknown',
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, { smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
tab_name: tabName, tab_name: tabName,
news_id: newsId, news_id: newsId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
* @param {string} filters.industryCode - 行业代码 * @param {string} filters.industryCode - 行业代码
*/ */
const trackNewsFilterApplied = useCallback((filters = {}) => { const trackNewsFilterApplied = useCallback((filters = {}) => {
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, { smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
importance: filters.importance || 'all', importance: filters.importance || 'all',
date_range: filters.dateRange || 'all', date_range: filters.dateRange || 'all',
industry_classification: filters.industryClassification || 'all', industry_classification: filters.industryClassification || 'all',
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_SORTED, { smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
sort_by: sortBy, sort_by: sortBy,
previous_sort: previousSort, previous_sort: previousSort,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
const trackNewsSearched = useCallback((query, resultCount = 0) => { const trackNewsSearched = useCallback((query, resultCount = 0) => {
if (!query) return; if (!query) return;
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
query, query,
result_count: resultCount, result_count: resultCount,
has_results: resultCount > 0, has_results: resultCount > 0,
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
// 如果没有搜索结果,额外追踪 // 如果没有搜索结果,额外追踪(高优先级,立即发送)
if (resultCount === 0) { if (resultCount === 0) {
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
query, query,
context: 'community_news', context: 'community_news',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.STOCK_CLICKED, { smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
stock_code: stock.code, stock_code: stock.code,
stock_name: stock.name || '', stock_name: stock.name || '',
source: 'news_related_stocks', source: 'news_related_stocks',
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.CONCEPT_CLICKED, { smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
concept_code: concept.code, concept_code: concept.code,
concept_name: concept.name || '', concept_name: concept.name || '',
source: 'news_related_concepts', source: 'news_related_concepts',

View File

@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
import { import {
fetchPopularKeywords, fetchPopularKeywords,
fetchHotEvents fetchHotEvents
} from '../../store/slices/communityDataSlice'; } from '@/store/slices/communityDataSlice';
import { import {
Box, Box,
Container, Container,
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters'; import { useEventFilters } from './hooks/useEventFilters';
import { useCommunityEvents } from './hooks/useCommunityEvents'; import { useCommunityEvents } from './hooks/useCommunityEvents';
import { logger } from '../../utils/logger'; import { logger } from '@/utils/logger';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '@/contexts/NotificationContext';
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
// 导航栏已由 MainLayout 提供,无需在此导入 // 导航栏已由 MainLayout 提供,无需在此导入
@@ -96,6 +97,15 @@ const Community = () => {
dispatch(fetchHotEvents()); dispatch(fetchHotEvents());
}, [dispatch]); }, [dispatch]);
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
useEffect(() => {
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
return () => {
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
};
}, []);
// 🎯 追踪新闻列表查看(当事件列表加载完成后) // 🎯 追踪新闻列表查看(当事件列表加载完成后)
useEffect(() => { useEffect(() => {
if (events && events.length > 0 && !loading) { if (events && events.length > 0 && !loading) {

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

View File

@@ -52,13 +52,13 @@ import {
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
import './InvestmentCalendar.css'; import './InvestmentCalendar.css';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
export default function InvestmentCalendarChakra() { export default function InvestmentCalendarChakra() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
// 处理日期点击 // 处理日期点击
const handleDateClick = (info) => { const handleDateClick = (info) => {
const clickedDate = moment(info.date); const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
// 筛选当天的事件 // 筛选当天的事件
const dayEvents = events.filter(event => const dayEvents = events.filter(event =>
moment(event.start).isSame(clickedDate, 'day') dayjs(event.start).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
// 处理事件点击 // 处理事件点击
const handleEventClick = (info) => { const handleEventClick = (info) => {
const event = info.event; const event = info.event;
const clickedDate = moment(event.start); const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
setSelectedDateEvents([{ setSelectedDateEvents([{
title: event.title, title: event.title,
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
const eventData = { const eventData = {
...newEvent, ...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), stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
}; };
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
leftIcon={<FiPlus />} leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }} onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
> >
添加计划 添加计划
</Button> </Button>

View File

@@ -66,13 +66,13 @@ import {
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
import '../components/InvestmentCalendar.css'; import '../components/InvestmentCalendar.css';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
// 创建 Context 用于跨标签页共享数据 // 创建 Context 用于跨标签页共享数据
const PlanningDataContext = createContext(); const PlanningDataContext = createContext();
@@ -232,11 +232,11 @@ function CalendarPanel() {
// 处理日期点击 // 处理日期点击
const handleDateClick = (info) => { const handleDateClick = (info) => {
const clickedDate = moment(info.date); const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(event => const dayEvents = allEvents.filter(event =>
moment(event.event_date).isSame(clickedDate, 'day') dayjs(event.event_date).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -245,11 +245,11 @@ function CalendarPanel() {
// 处理事件点击 // 处理事件点击
const handleEventClick = (info) => { const handleEventClick = (info) => {
const event = info.event; const event = info.event;
const clickedDate = moment(event.start); const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(ev => const dayEvents = allEvents.filter(ev =>
moment(ev.event_date).isSame(clickedDate, 'day') dayjs(ev.event_date).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -262,7 +262,7 @@ function CalendarPanel() {
const eventData = { const eventData = {
...newEvent, ...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), stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
}; };
@@ -368,7 +368,7 @@ function CalendarPanel() {
size="sm" size="sm"
colorScheme="purple" colorScheme="purple"
leftIcon={<FiPlus />} leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }} onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
> >
添加计划 添加计划
</Button> </Button>
@@ -619,7 +619,7 @@ function PlansPanel() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -638,13 +638,13 @@ function PlansPanel() {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...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 || '', content: item.description || item.content || '',
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -795,7 +795,7 @@ function PlansPanel() {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" 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> </Text>
<Badge <Badge
colorScheme={statusInfo.color} colorScheme={statusInfo.color}
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'review', type: 'review',
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...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 || '', content: item.description || item.content || '',
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'review', type: 'review',
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" 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> </Text>
</HStack> </HStack>
</VStack> </VStack>

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

View File

@@ -60,12 +60,12 @@ import {
FiXCircle, FiXCircle,
FiAlertCircle, FiAlertCircle,
} from 'react-icons/fi'; } from 'react-icons/fi';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
export default function InvestmentPlansAndReviews({ type = 'both' }) { export default function InvestmentPlansAndReviews({ type = 'both' }) {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -83,7 +83,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -134,12 +134,12 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...item,
date: moment(item.date).format('YYYY-MM-DD'), date: dayjs(item.date).format('YYYY-MM-DD'),
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: itemType, type: itemType,
@@ -291,7 +291,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}> <Text fontSize="sm" color={secondaryText}>
{moment(item.date).format('YYYY年MM月DD日')} {dayjs(item.date).format('YYYY年MM月DD日')}
</Text> </Text>
<Badge <Badge
colorScheme={statusInfo.color} colorScheme={statusInfo.color}

View File

@@ -29,7 +29,7 @@ import {
} from 'react-icons/fi'; } from 'react-icons/fi';
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import moment from 'moment'; import dayjs from 'dayjs';
export default function MyFutureEvents({ limit = 5 }) { export default function MyFutureEvents({ limit = 5 }) {
const [futureEvents, setFutureEvents] = useState([]); const [futureEvents, setFutureEvents] = useState([]);
@@ -51,7 +51,7 @@ export default function MyFutureEvents({ limit = 5 }) {
if (response.success) { if (response.success) {
// 按时间排序,最近的在前 // 按时间排序,最近的在前
const sortedEvents = (response.data || []).sort((a, b) => 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); setFutureEvents(sortedEvents);
logger.debug('MyFutureEvents', '未来事件加载成功', { logger.debug('MyFutureEvents', '未来事件加载成功', {
@@ -98,8 +98,8 @@ export default function MyFutureEvents({ limit = 5 }) {
// 格式化时间 // 格式化时间
const formatEventTime = (time) => { const formatEventTime = (time) => {
const eventTime = moment(time); const eventTime = dayjs(time);
const now = moment(); const now = dayjs();
const daysDiff = eventTime.diff(now, 'days'); const daysDiff = eventTime.diff(now, 'days');
if (daysDiff === 0) { if (daysDiff === 0) {

View 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;
};

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

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

View File

@@ -30,7 +30,7 @@ import {
Divider Divider
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa'; import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment'; import dayjs from 'dayjs';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具 import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
@@ -326,7 +326,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof tradeDate === 'string') { if (typeof tradeDate === 'string') {
formattedTradeDate = tradeDate; formattedTradeDate = tradeDate;
} else if (tradeDate instanceof Date) { } else if (tradeDate instanceof Date) {
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD'); formattedTradeDate = dayjs(tradeDate).format('YYYY-MM-DD');
} else if (moment.isMoment(tradeDate)) { } else if (moment.isMoment(tradeDate)) {
formattedTradeDate = tradeDate.format('YYYY-MM-DD'); formattedTradeDate = tradeDate.format('YYYY-MM-DD');
} else { } else {
@@ -334,7 +334,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
tradeDate, tradeDate,
tradeDateType: typeof tradeDate tradeDateType: typeof tradeDate
}); });
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
const requestBody = { const requestBody = {
@@ -414,18 +414,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
// 检查是否是Date对象 // 检查是否是Date对象
if (eventTime instanceof Date) { if (eventTime instanceof Date) {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'string') { } else if (typeof eventTime === 'string') {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'number') { } else if (typeof eventTime === 'number') {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else { } else {
logger.warn('RelatedConcepts', '未知的事件时间格式', { logger.warn('RelatedConcepts', '未知的事件时间格式', {
eventTime, eventTime,
eventTimeType: typeof eventTime, eventTimeType: typeof eventTime,
eventId eventId
}); });
eventMoment = moment(); eventMoment = dayjs();
} }
// 确保moment对象有效 // 确保moment对象有效
@@ -434,7 +434,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
eventTime, eventTime,
eventId eventId
}); });
eventMoment = moment(); eventMoment = dayjs();
} }
formattedDate = eventMoment.format('YYYY-MM-DD'); formattedDate = eventMoment.format('YYYY-MM-DD');
@@ -448,7 +448,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof nextTradingDay === 'string') { if (typeof nextTradingDay === 'string') {
formattedDate = nextTradingDay; formattedDate = nextTradingDay;
} else if (nextTradingDay instanceof Date) { } else if (nextTradingDay instanceof Date) {
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD'); formattedDate = dayjs(nextTradingDay).format('YYYY-MM-DD');
} else { } else {
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', { logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
nextTradingDay, nextTradingDay,
@@ -476,16 +476,16 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof currentTradingDay === 'string') { if (typeof currentTradingDay === 'string') {
formattedDate = currentTradingDay; formattedDate = currentTradingDay;
} else if (currentTradingDay instanceof Date) { } else if (currentTradingDay instanceof Date) {
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD'); formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
} else { } else {
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', { logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
currentTradingDay, currentTradingDay,
eventId eventId
}); });
formattedDate = moment().format('YYYY-MM-DD'); formattedDate = dayjs().format('YYYY-MM-DD');
} }
} else { } 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} /> <FaCalendarAlt color={textColor} />
<Text fontSize="sm" color={textColor}> <Text fontSize="sm" color={textColor}>
涨跌幅数据日期{effectiveTradingDate} 涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && ( {eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs"> <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>
)} )}
</Text> </Text>

View File

@@ -195,9 +195,12 @@ const EnhancedCalendar = ({
onClick={() => onDateChange(date)} onClick={() => onDateChange(date)}
transition="all 0.2s" transition="all 0.2s"
cursor="pointer" cursor="pointer"
display="flex"
alignItems="center"
justifyContent="center"
> >
<Text <Text
fontSize={compact ? 'md' : 'lg'} fontSize={compact ? 'lg' : 'xl'}
fontWeight={isToday || isSelected ? 'bold' : 'normal'} fontWeight={isToday || isSelected ? 'bold' : 'normal'}
color={isSelected ? 'blue.600' : 'gray.700'} color={isSelected ? 'blue.600' : 'gray.700'}
> >
@@ -206,13 +209,13 @@ const EnhancedCalendar = ({
{hasData && ( {hasData && (
<Badge <Badge
position="absolute" position="absolute"
top="2px" top="4px"
right="2px" right="4px"
size={compact ? 'sm' : 'md'} size={compact ? 'sm' : 'md'}
colorScheme={getDateBadgeColor(dateData.count)} colorScheme={getDateBadgeColor(dateData.count)}
fontSize={compact ? '10px' : '11px'} fontSize={compact ? '9px' : '10px'}
px={compact ? 1 : 2} px={compact ? 1 : 2}
minW={compact ? '22px' : '28px'} minW={compact ? '20px' : '24px'}
borderRadius="full" borderRadius="full"
> >
{dateData.count} {dateData.count}
@@ -221,7 +224,7 @@ const EnhancedCalendar = ({
{isToday && ( {isToday && (
<Text <Text
position="absolute" position="absolute"
bottom="2px" bottom="4px"
left="50%" left="50%"
transform="translateX(-50%)" transform="translateX(-50%)"
fontSize={compact ? '9px' : '10px'} fontSize={compact ? '9px' : '10px'}

View File

@@ -444,7 +444,6 @@ export default function LimitAnalyse() {
borderColor="whiteAlpha.300" borderColor="whiteAlpha.300"
backdropFilter="saturate(180%) blur(10px)" backdropFilter="saturate(180%) blur(10px)"
w="full" w="full"
minH="420px"
> >
<CardBody p={4}> <CardBody p={4}>
<EnhancedCalendar <EnhancedCalendar
@@ -453,8 +452,9 @@ export default function LimitAnalyse() {
availableDates={availableDates} availableDates={availableDates}
compact compact
hideSelectionInfo hideSelectionInfo
hideLegend
width="100%" width="100%"
cellHeight={10} cellHeight={16}
/> />
</CardBody> </CardBody>
</Card> </Card>