Compare commits

..

45 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
zdl
163c55f819 refactor: 重构 NotificationContext.js 日志系统,替换所有 console 调用为 logger
## 主要改动
- 将 47 处 console.log/warn/error 全部替换为 logger 方法
- 删除 6 处装饰性分隔线(%c════════)
- 合并冗余日志,优化日志结构
- 减少代码行数:1021 → 1003(-18 行)

## 日志分类
- logger.info (43 处):重要操作(连接、订阅、通知发送)
- logger.debug (17 处):详细调试信息(数据内容、中间状态)
- logger.warn (5 处):警告信息(权限问题、重复事件、断开连接)
- logger.error (9 处):错误信息(失败、异常、Ref 未初始化)

## 优化效果
-  生产环境可通过 REACT_APP_ENABLE_DEBUG 控制日志输出
-  日志更规范,带时间戳和统一格式
-  console.log 调用:47 → 0(100% 清理完成)
-  代码更清洁,无装饰性代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:10:41 +08:00
zdl
990d1ca0bc perf: 使用 React.memo 优化社区组件渲染性能
**优化目标**:
- 减少组件卸载次数:从 6 次/刷新 → 1-2 次/刷新(↓ 66-83%)
- 减少渲染次数:从 9 次/刷新 → 4-5 次/刷新(↓ 44-55%)

**优化组件**(共 7 个):
1.  ModeToggleButtons.js - 简单 UI 组件
2.  DynamicNewsEventCard.js - 平铺模式卡片(被渲染 30+ 次)
3.  HorizontalDynamicNewsEventCard.js - 纵向模式卡片(被渲染 10+ 次)
4.  VerticalModeLayout.js - 布局组件
5.  EventScrollList.js - 列表组件
6.  VirtualizedFourRowGrid.js - 虚拟化网格(forwardRef)
7.  DynamicNewsCard.js - 主组件(forwardRef)

**技术实现**:
- 普通组件:`React.memo(Component)`
- forwardRef 组件:`React.memo(forwardRef(...))`
- 所有回调函数已使用 useCallback 确保引用稳定

**预期效果**:
- 列表渲染的卡片组件收益最大(减少 90% 重渲染)
- 布局组件渲染次数从 9 次降到 1 次(减少 88%)
- 整体用户体验更流畅,无明显卡顿

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:00:46 +08:00
zdl
3fe2d2bdc9 pref: 删除NotificationTestTool 组件, notificationDebugger.js 调试工具删除完成 2025-11-17 14:59:39 +08:00
zdl
a9f0c5ced2 pref: 删除测试 API(优先)通知 2025-11-17 14:47:24 +08:00
zdl
9b355b402d feat: 升级 logger logger 支持环境变量控制 2025-11-17 14:33:39 +08:00
zdl
3cadd02492 fix: 修复 Socket 新事件通知后不刷新列表的问题
问题描述:
- 用户在第1页时收到新事件 Socket 通知
- 系统调用 handlePageChange(1) 想要刷新列表
- 但列表未刷新,新事件不显示
- 控制台日志显示"⚠️ 重复点击当前页: 1"

根本原因:
- usePagination 的 handlePageChange 函数有"重复点击"检查
- 当 newPage === currentPage 时直接 return(第 169-174 行)
- Socket 新事件触发刷新时,当前在第1页,调用 handlePageChange(1)
- 函数误认为是"用户重复点击分页按钮",阻止了刷新

设计冲突:
- 原始设计:防止用户重复点击分页按钮,避免不必要的 API 请求 
- 副作用:阻止了 Socket 新事件触发的强制刷新逻辑 

修复方案(添加 force 参数):
1. 修改 handlePageChange 函数签名:(newPage, force = false)
   - force = true: 强制刷新(绕过"重复点击"检查)
   - force = false: 正常翻页(保留原有检查)

2. 修改边界检查逻辑(第 173-184 行):
   - 只有在非强制模式下才检查重复点击
   - 强制模式下,即使页码相同也继续执行
   - 添加日志:🔄 [翻页] 强制刷新当前页

3. 修改 Socket 新事件刷新调用(DynamicNewsCard.js:231):
   - 修改前:handlePageChange(1)
   - 修改后:handlePageChange(1, true)  // force = true

修改文件:
- src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
  - 第 161 行:修改函数签名(添加 force 参数)
  - 第 173-184 行:修改边界检查逻辑(添加 force 判断)

- src/views/Community/components/DynamicNewsCard.js
  - 第 230-231 行:修改 handlePageChange 调用(传递 force: true)

修复效果:
-  收到新事件后,第1页强制刷新并显示新事件
-  保留原有的"重复点击"优化(普通翻页)
-  不影响其他页面的用户体验(第2页及以上不打断)
-  清晰的日志区分:
  - 🔄 [翻页] 强制刷新当前页: 1(强制刷新)
  - ⚠️ [翻页] 重复点击当前页: 2(阻止重复点击)

🤖 Generated with Claude Code
2025-11-17 12:12:01 +08:00
zdl
d69a32a320 fix: 修复个人中心不显示新发表的评论问题
问题描述:
- 用户在事件中心发表评论后,打开个人中心看不到新评论
- 个人中心"我的评论"区域始终为空或显示旧数据

根本原因:
- 项目存在两套独立的评论系统:
  1. 旧系统(EventComment 表)- 个人中心查询此表
  2. 新系统(Post 表)- 事件中心写入此表
- 创建评论时写入 Post 表,但个人中心查询 EventComment 表
- 两个表完全独立,数据不同步

修复方案(统一到 Post 系统):
1. 后端新增 API:GET /api/account/events/posts
   - 查询 Post 表中当前用户的所有评论
   - 返回格式完全兼容旧 EventComment.to_dict()
   - 新增 event_title 字段(改进点,旧 API 没有)

2. 前端修改 API 调用:Center.js
   - 将 /api/account/events/comments 改为 /api/account/events/posts
   - 无需修改数据渲染逻辑(格式兼容)

修改文件:
- app.py (第 4144-4187 行) - 新增 get_my_event_posts API
  - 查询 Post 表(user_id 过滤 + 按时间倒序)
  - JOIN 查询关联的 Event(获取 event_title)
  - 返回兼容格式:author(字符串), likes, created_at, event_title

- src/views/Dashboard/Center.js (第 105 行) - 修改 API 调用路径
  - 修改前:GET /api/account/events/comments
  - 修改后:GET /api/account/events/posts

数据兼容性:
- author 字段:字符串类型(与旧 EventComment 一致)
- likes 字段:映射自 likes_count
- created_at 字段:ISO 8601 格式
- 新增:event_title 字段(个人中心可显示评论关联的事件)

修复效果:
- 用户在事件中心发表评论 → 立即在个人中心看到新评论 
- 评论显示完整信息:内容、时间、关联事件标题 
- 前端无需修改渲染逻辑(完全兼容) 

🤖 Generated with Claude Code
2025-11-17 11:25:18 +08:00
zdl
8d3327e4dd fix: 修复评论用户名显示不一致问题(乐观更新显示正确,刷新后显示 Anonymous)
问题描述:
- 用户发表评论时,乐观更新显示用户名 "zdl"
- 但 API 返回后,用户名变成 "Anonymous"
- 刷新页面后,用户名仍然是 "Anonymous"

根本原因:
- 前端代码期望评论对象包含 `author` 字段(src/types/comment.ts)
- 后端 API 返回的是 `user` 字段(app.py:7710-7714)
- 前端渲染时读取 comment.author?.username(CommentItem.js:72)
- 因为 comment.author 不存在,所以显示 'Anonymous'

修复方案:
- 在 eventService.getPosts 中添加数据转换逻辑
- 将后端返回的 user 字段映射为前端期望的 author 字段
- 兼容 avatar_url 和 avatar 两种字段名
- 处理 user 为 null 的边界情况(显示 Anonymous)

影响范围:
- src/services/eventService.js - getPosts 函数数据转换
- 所有使用 getPosts API 的组件都会受益于此修复
- 保持类型定义不变,符合业务语义

修复效果:
- 乐观更新显示:zdl 刚刚 打卡
- API 返回后显示:zdl 刚刚 打卡 (之前会变成 Anonymous)
- 刷新页面后显示:zdl XX分钟前 打卡 

🤖 Generated with Claude Code
2025-11-17 11:08:59 +08:00
zdl
3a02c13dfe fix: 修复评论在所有事件中串联显示的严重 Bug
问题描述:
- 在事件 A 下发表评论后,该评论会出现在事件 B、C 等所有事件下
- 切换事件时,评论列表没有重新加载,导致数据混乱

根本原因:
- usePagination Hook 的 useEffect 只依赖 autoLoad(常量)
- 当 eventId 变化时,loadCommentsFunction 被重新创建(包含新的 eventId)
- 但 useEffect 不会重新执行,导致旧数据(上一个事件的评论)持续显示

修复方案:
- 在 useEffect 依赖数组中添加 loadFunction
- 当 loadFunction 变化时(eventId 变化 → loadCommentsFunction 变化)
- useEffect 重新执行,加载新事件的评论数据

影响范围:
- EventCommentSection 组件(评论区)
- 所有使用 usePagination Hook 的组件都会受益于此修复
- 确保数据隔离性和正确性

🤖 Generated with Claude Code
2025-11-17 10:30:57 +08:00
d28915ac90 add forum 2025-11-15 10:09:17 +08:00
b2f3a8f140 add forum 2025-11-15 09:50:55 +08:00
3014317c12 add forum 2025-11-15 09:15:54 +08:00
2013a0f868 Merge branch 'feature_bugfix/251113_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251113_ui 2025-11-15 09:11:57 +08:00
05b497de29 add forum 2025-11-15 09:10:26 +08:00
zdl
d9013d1e85 Merge branch 'feature_bugfix/251113_bugfix' into feature_bugfix/251113_ui
* feature_bugfix/251113_bugfix:
  feat: 实现 Socket 触发的智能列表自动刷新功能(带防抖)
  feat: 实现评论分页功能并迁移到 TypeScript
  feat: 接入Ts配置
  feat: 添加评论功能
2025-11-14 19:04:45 +08:00
2753fbc37f update ui 2025-11-14 18:48:39 +08:00
43de7f7a52 update ui 2025-11-14 18:03:55 +08:00
90 changed files with 7966 additions and 30920 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 优化

69
app.py
View File

@@ -1602,7 +1602,7 @@ def calculate_subscription_price():
data = request.get_json() data = request.get_json()
to_plan = data.get('to_plan') to_plan = data.get('to_plan')
to_cycle = data.get('to_cycle') to_cycle = data.get('to_cycle')
promo_code = data.get('promo_code', '').strip() or None promo_code = (data.get('promo_code') or '').strip() or None
if not to_plan or not to_cycle: if not to_plan or not to_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400 return jsonify({'success': False, 'error': '参数不完整'}), 400
@@ -1638,7 +1638,7 @@ def create_payment_order():
data = request.get_json() data = request.get_json()
plan_name = data.get('plan_name') plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle') billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code', '').strip() or None promo_code = (data.get('promo_code') or '').strip() or None
if not plan_name or not billing_cycle: if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400 return jsonify({'success': False, 'error': '参数不完整'}), 400
@@ -3424,8 +3424,20 @@ def login_with_wechat():
# 更新最后登录时间 # 更新最后登录时间
user.update_last_seen() user.update_last_seen()
# 清除session # ✅ 修复不立即删除session而是标记为已完成避免轮询报错
del wechat_qr_sessions[session_id] # 原因:前端可能还在轮询检查状态,立即删除会导致 "无效的session" 错误
# 保留原状态login_ready/register_ready前端会正确处理
# wechat_qr_sessions[session_id]['status'] 保持不变
# 设置延迟删除10秒后自动清理给前端足够时间完成轮询
import threading
def delayed_cleanup():
import time
time.sleep(10)
if session_id in wechat_qr_sessions:
del wechat_qr_sessions[session_id]
print(f"✅ 延迟清理微信登录session: {session_id[:8]}...")
threading.Thread(target=delayed_cleanup, daemon=True).start()
# 生成登录响应 # 生成登录响应
response_data = { response_data = {
@@ -3442,7 +3454,8 @@ def login_with_wechat():
'wechat_union_id': user.wechat_union_id, 'wechat_union_id': user.wechat_union_id,
'created_at': user.created_at.isoformat() if user.created_at else None, 'created_at': user.created_at.isoformat() if user.created_at else None,
'last_seen': user.last_seen.isoformat() if user.last_seen else None 'last_seen': user.last_seen.isoformat() if user.last_seen else None
} },
'isNewUser': session['status'] == 'register_ready' # 标记是否为新用户
} }
# 如果需要token认证可以在这里生成 # 如果需要token认证可以在这里生成
@@ -4128,6 +4141,52 @@ def get_my_event_comments():
return jsonify({'success': True, 'data': [c.to_dict() for c in comments]}) return jsonify({'success': True, 'data': [c.to_dict() for c in comments]})
@app.route('/api/account/events/posts', methods=['GET'])
def get_my_event_posts():
"""获取我在事件上的帖子Post- 用于个人中心显示"""
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
try:
# 查询当前用户的所有 Post按创建时间倒序
posts = Post.query.filter_by(
user_id=session['user_id'],
status='active'
).order_by(Post.created_at.desc()).limit(100).all()
posts_data = []
for post in posts:
# 获取关联的事件信息
event = Event.query.get(post.event_id)
event_title = event.title if event else '未知事件'
# 获取用户信息
user = User.query.get(post.user_id)
author = user.username if user else '匿名用户'
# ⚡ 返回格式兼容旧 EventComment.to_dict()
posts_data.append({
'id': post.id,
'event_id': post.event_id,
'event_title': event_title, # ⚡ 新增字段(旧 API 没有)
'user_id': post.user_id,
'author': author, # ⚡ 兼容旧格式(字符串类型)
'content': post.content,
'title': post.title, # Post 独有字段(可选)
'content_type': post.content_type, # Post 独有字段
'likes': post.likes_count, # ⚡ 兼容旧字段名
'created_at': post.created_at.isoformat(),
'updated_at': post.updated_at.isoformat(),
'status': post.status,
})
return jsonify({'success': True, 'data': posts_data})
except Exception as e:
print(f"获取用户帖子失败: {e}")
return jsonify({'success': False, 'error': '获取帖子失败'}), 500
@app.route('/api/account/future-events/following', methods=['GET']) @app.route('/api/account/future-events/following', methods=['GET'])
def get_my_following_future_events(): def get_my_following_future_events():
"""获取当前用户关注的未来事件""" """获取当前用户关注的未来事件"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

145
init-forum-es.js Normal file
View File

@@ -0,0 +1,145 @@
/**
* 初始化价值论坛 Elasticsearch 索引
* 运行方式node init-forum-es.js
*/
const axios = require('axios');
// Elasticsearch 配置
const ES_BASE_URL = 'http://222.128.1.157:19200';
// 创建 axios 实例
const esClient = axios.create({
baseURL: ES_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 索引名称
const INDICES = {
POSTS: 'forum_posts',
COMMENTS: 'forum_comments',
EVENTS: 'forum_events',
};
async function initializeIndices() {
try {
console.log('开始初始化 Elasticsearch 索引...\n');
// 1. 创建帖子索引
console.log('创建帖子索引 (forum_posts)...');
try {
await esClient.put(`/${INDICES.POSTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
title: { type: 'text' },
content: { type: 'text' },
images: { type: 'keyword' },
tags: { type: 'keyword' },
category: { type: 'keyword' },
likes_count: { type: 'integer' },
comments_count: { type: 'integer' },
views_count: { type: 'integer' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
is_pinned: { type: 'boolean' },
status: { type: 'keyword' },
},
},
});
console.log('✅ 帖子索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 帖子索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 2. 创建评论索引
console.log('创建评论索引 (forum_comments)...');
try {
await esClient.put(`/${INDICES.COMMENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
content: { type: 'text' },
parent_id: { type: 'keyword' },
likes_count: { type: 'integer' },
created_at: { type: 'date' },
status: { type: 'keyword' },
},
},
});
console.log('✅ 评论索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 评论索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 3. 创建事件时间轴索引
console.log('创建事件时间轴索引 (forum_events)...');
try {
await esClient.put(`/${INDICES.EVENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
event_type: { type: 'keyword' },
title: { type: 'text' },
description: { type: 'text' },
source: { type: 'keyword' },
source_url: { type: 'keyword' },
related_stocks: { type: 'keyword' },
occurred_at: { type: 'date' },
created_at: { type: 'date' },
importance: { type: 'keyword' },
},
},
});
console.log('✅ 事件时间轴索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 事件时间轴索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 4. 验证索引
console.log('验证索引...');
const indices = await esClient.get('/_cat/indices/forum_*?v&format=json');
console.log('已创建的论坛索引:');
indices.data.forEach(index => {
console.log(` - ${index.index} (docs: ${index['docs.count']}, size: ${index['store.size']})`);
});
console.log('\n🎉 所有索引初始化完成!');
console.log('\n下一步');
console.log('1. 访问 https://valuefrontier.cn/value-forum');
console.log('2. 点击"发布帖子"按钮创建第一篇帖子');
} catch (error) {
console.error('❌ 初始化失败:', error.message);
if (error.response) {
console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
}
process.exit(1);
}
}
// 执行初始化
initializeIndices();

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",
@@ -78,7 +78,8 @@
"styled-components": "^5.3.11", "styled-components": "^5.3.11",
"stylis": "^4.0.10", "stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1", "stylis-plugin-rtl": "^2.1.1",
"tsparticles-slim": "^2.12.0" "tsparticles-slim": "^2.12.0",
"typescript": "^5.9.3"
}, },
"resolutions": { "resolutions": {
"react-error-overlay": "6.0.9", "react-error-overlay": "6.0.9",
@@ -138,7 +139,6 @@
"react-error-overlay": "6.0.9", "react-error-overlay": "6.0.9",
"sharp": "^0.34.4", "sharp": "^0.34.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0" "yn": "^5.1.0"
}, },

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

@@ -0,0 +1,96 @@
#!/bin/bash
# 初始化价值论坛 Elasticsearch 索引
# 使用 Nginx 代理或直连 ES
set -e
echo "🚀 开始初始化价值论坛 Elasticsearch 索引..."
echo ""
# ES 地址(根据环境选择)
if [ -n "$USE_PROXY" ]; then
ES_URL="https://valuefrontier.cn/es-api"
echo "📡 使用 Nginx 代理: $ES_URL"
else
ES_URL="http://222.128.1.157:19200"
echo "📡 直连 Elasticsearch: $ES_URL"
fi
echo ""
# 1. 创建帖子索引
echo "📝 创建帖子索引 (forum_posts)..."
curl -X PUT "$ES_URL/forum_posts" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"author_id": { "type": "keyword" },
"author_name": { "type": "text" },
"author_avatar": { "type": "keyword" },
"title": { "type": "text" },
"content": { "type": "text" },
"images": { "type": "keyword" },
"tags": { "type": "keyword" },
"category": { "type": "keyword" },
"likes_count": { "type": "integer" },
"comments_count": { "type": "integer" },
"views_count": { "type": "integer" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"is_pinned": { "type": "boolean" },
"status": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 帖子索引创建成功" || echo "⚠️ 帖子索引已存在或创建失败"
echo ""
# 2. 创建评论索引
echo "💬 创建评论索引 (forum_comments)..."
curl -X PUT "$ES_URL/forum_comments" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"post_id": { "type": "keyword" },
"author_id": { "type": "keyword" },
"author_name": { "type": "text" },
"author_avatar": { "type": "keyword" },
"content": { "type": "text" },
"parent_id": { "type": "keyword" },
"likes_count": { "type": "integer" },
"created_at": { "type": "date" },
"status": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 评论索引创建成功" || echo "⚠️ 评论索引已存在或创建失败"
echo ""
# 3. 创建事件时间轴索引
echo "⏰ 创建事件时间轴索引 (forum_events)..."
curl -X PUT "$ES_URL/forum_events" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"post_id": { "type": "keyword" },
"event_type": { "type": "keyword" },
"title": { "type": "text" },
"description": { "type": "text" },
"source": { "type": "keyword" },
"source_url": { "type": "keyword" },
"related_stocks": { "type": "keyword" },
"occurred_at": { "type": "date" },
"created_at": { "type": "date" },
"importance": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 事件时间轴索引创建成功" || echo "⚠️ 事件时间轴索引已存在或创建失败"
echo ""
# 4. 验证索引
echo "🔍 验证已创建的索引..."
curl -X GET "$ES_URL/_cat/indices/forum_*?v" 2>/dev/null
echo ""
echo "🎉 初始化完成!"
echo ""
echo "下一步:"
echo " 1. 访问 https://valuefrontier.cn/value-forum"
echo " 2. 点击"发布帖子"按钮创建第一篇帖子"

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

@@ -9,7 +9,6 @@ import { logger } from '../utils/logger';
// Global Components // Global Components
import AuthModalManager from './Auth/AuthModalManager'; import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer'; import NotificationContainer from './NotificationContainer';
import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar'; import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop'; import ScrollToTop from './ScrollToTop';
@@ -71,7 +70,6 @@ function ConnectionStatusBarWrapper() {
* - ScrollToTop: 路由切换时自动滚动到顶部 * - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器 * - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器 * - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏) * - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/ */
export function GlobalComponents() { export function GlobalComponents() {
@@ -92,9 +90,6 @@ export function GlobalComponents() {
{/* 通知容器 */} {/* 通知容器 */}
<NotificationContainer /> <NotificationContainer />
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */} {/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && ( {showBytedesk && (
<BytedeskWidget <BytedeskWidget

View File

@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
</HStack> </HStack>
</Link> </Link>
<Link <Link
onClick={() => handleNavigate('/value-forum')}
py={1} py={1}
px={3} px={3}
borderRadius="md" borderRadius="md"
_hover={{}} _hover={{ bg: 'gray.50' }}
cursor="not-allowed" bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
color="gray.400"
pointerEvents="none"
> >
<Text fontSize="sm" color="gray.400">今日热议</Text> <HStack justify="space-between">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="yellow">黑金</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link> </Link>
<Link <Link
py={1} py={1}

View File

@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex> </Flex>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
isDisabled onClick={() => {
cursor="not-allowed" navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
color="gray.400" navigate('/value-forum');
}}
borderRadius="md"
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/value-forum') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/value-forum') ? 'bold' : 'normal'}
> >
<Text fontSize="sm" color="gray.400">今日热议</Text> <Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="yellow">黑金</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
isDisabled isDisabled

View File

@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</HStack> </HStack>
</Flex> </Flex>
</MenuItem> </MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400"> <MenuItem
<Text fontSize="sm" color="gray.400">今日热议</Text> onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/value-forum');
}}
borderRadius="md"
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="yellow">黑金</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem> </MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400"> <MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">个股社区</Text> <Text fontSize="sm" color="gray.400">个股社区</Text>

View File

@@ -1,663 +0,0 @@
// src/components/NotificationTestTool/index.js
/**
* 金融资讯通知测试工具 - 仅在开发环境显示
* 用于手动测试4种通知类型
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
VStack,
HStack,
Text,
IconButton,
Collapse,
useDisclosure,
Badge,
Divider,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Code,
UnorderedList,
ListItem,
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
const NotificationTestTool = () => {
// 只在开发环境显示 - 必须在所有 Hooks 调用之前检查
if (process.env.NODE_ENV !== 'development') {
return null;
}
const { isOpen, onToggle } = useDisclosure();
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
const [testCount, setTestCount] = useState(0);
// 测试状态
const [isTestingNotification, setIsTestingNotification] = useState(false);
const [testCountdown, setTestCountdown] = useState(0);
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
// 系统环境检测
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
// 故障排查面板
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
// 检测系统环境
useEffect(() => {
// 检测是否为 macOS
const platform = navigator.platform.toLowerCase();
setIsMacOS(platform.includes('mac'));
// 检测全屏状态
const checkFullscreen = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', checkFullscreen);
checkFullscreen();
return () => {
document.removeEventListener('fullscreenchange', checkFullscreen);
};
}, []);
// 倒计时逻辑
useEffect(() => {
if (testCountdown > 0) {
const timer = setTimeout(() => {
setTestCountdown(testCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (testCountdown === 0 && isTestingNotification) {
// 倒计时结束,询问用户
setIsTestingNotification(false);
// 延迟一下再询问,确保用户有时间看到通知
setTimeout(() => {
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
setNotificationShown(sawNotification);
if (!sawNotification) {
// 没看到通知,展开故障排查面板
if (!isTroubleshootOpen) {
onTroubleshootToggle();
}
}
}, 500);
}
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
// 浏览器权限状态标签
const getPermissionLabel = () => {
switch (browserPermission) {
case 'granted':
return '已授权';
case 'denied':
return '已拒绝';
case 'default':
return '未授权';
default:
return '不支持';
}
};
const getPermissionColor = () => {
switch (browserPermission) {
case 'granted':
return 'green';
case 'denied':
return 'red';
case 'default':
return 'gray';
default:
return 'gray';
}
};
// 请求浏览器权限
const handleRequestPermission = async () => {
await requestBrowserPermission();
};
// 公告通知测试数据
const testAnnouncement = () => {
addNotification({
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】贵州茅台发布2024年度财报公告',
content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
},
autoClose: 10000,
});
setTestCount(prev => prev + 1);
};
// 事件动向测试数据
const testEventAlert = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test003',
extra: {
eventId: 'test003',
relatedStocks: 12,
impactLevel: '重大利好',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
};
// 分析报告测试数据非AI
const testAnalysisReport = () => {
addNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=test004',
extra: {
reportType: '行业研报',
industry: '医药',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
};
// 预测通知测试数据(不可跳转)
const testPrediction = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】央行可能宣布降准政策',
content: '基于最新宏观数据分析预计央行将在本周宣布降准0.5个百分点,释放长期资金',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
};
// 预测→详情流程测试先推预测5秒后推详情
const testPredictionFlow = () => {
// 阶段 1: 推送预测
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】新能源汽车补贴政策将延期',
content: '根据政策趋势分析预计财政部将宣布新能源汽车购置补贴政策延长至2025年底',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false,
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_test_001',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
// 阶段 2: 5秒后推送详情
setTimeout(() => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】新能源汽车补贴政策延期至2025年底',
content: '财政部宣布新能源汽车购置补贴政策延长至2025年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true, // ✅ 可点击
link: '/event-detail/test_pred_001',
extra: {
isPrediction: false,
relatedPredictionId: 'pred_test_001',
eventId: 'test_pred_001',
relatedStocks: 5,
impactLevel: '重大利好',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
}, 5000);
};
return (
<Box
position="fixed"
top="116px"
right={4}
zIndex={9998}
bg="white"
borderRadius="md"
boxShadow="lg"
overflow="hidden"
>
{/* 折叠按钮 */}
<HStack
p={2}
bg="blue.500"
color="white"
cursor="pointer"
onClick={onToggle}
spacing={2}
>
<MdNotifications size={20} />
<Text fontSize="sm" fontWeight="bold">
金融资讯测试工具
</Text>
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
<Badge colorScheme="purple">
REAL
</Badge>
<Badge colorScheme={getPermissionColor()}>
浏览器: {getPermissionLabel()}
</Badge>
<IconButton
icon={isOpen ? <MdClose /> : <MdNotifications />}
size="xs"
variant="ghost"
colorScheme="whiteAlpha"
aria-label={isOpen ? '关闭' : '打开'}
/>
</HStack>
{/* 工具面板 */}
<Collapse in={isOpen} animateOpacity>
<VStack p={4} spacing={3} align="stretch" minW="280px">
<Text fontSize="xs" color="gray.600" fontWeight="bold">
通知类型测试
</Text>
{/* 公告通知 */}
<Button
size="sm"
colorScheme="blue"
leftIcon={<MdCampaign />}
onClick={testAnnouncement}
>
公告通知
</Button>
{/* 事件动向 */}
<Button
size="sm"
colorScheme="orange"
leftIcon={<MdArticle />}
onClick={testEventAlert}
>
事件动向
</Button>
{/* 分析报告 */}
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
>
分析报告
</Button>
{/* 预测通知 */}
<Button
size="sm"
colorScheme="gray"
leftIcon={<MdArticle />}
onClick={testPrediction}
>
预测通知不可跳转
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
组合测试
</Text>
{/* 预测→详情流程测试 */}
<Button
size="sm"
colorScheme="cyan"
onClick={testPredictionFlow}
>
预测详情流程5秒延迟
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
浏览器通知
</Text>
{/* 请求权限按钮 */}
{browserPermission !== 'granted' && (
<Button
size="sm"
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
onClick={handleRequestPermission}
isDisabled={browserPermission === 'denied'}
>
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
</Button>
)}
{/* 测试浏览器通知按钮 */}
{browserPermission === 'granted' && (
<Button
size="sm"
colorScheme="green"
leftIcon={<MdNotifications />}
onClick={() => {
console.log('测试浏览器通知按钮被点击');
console.log('Notification support:', 'Notification' in window);
console.log('Notification permission:', Notification?.permission);
console.log('Platform:', navigator.platform);
console.log('Fullscreen:', !!document.fullscreenElement);
// 直接使用原生 Notification API 测试
if (!('Notification' in window)) {
alert('您的浏览器不支持桌面通知');
return;
}
if (Notification.permission !== 'granted') {
alert('浏览器通知权限未授予\n当前权限状态' + Notification.permission);
return;
}
// 重置状态
setNotificationShown(null);
setIsTestingNotification(true);
setTestCountdown(8); // 8秒倒计时
try {
console.log('正在创建浏览器通知...');
const notification = new Notification('【测试】浏览器通知测试', {
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
icon: '/logo192.png',
badge: '/badge.png',
tag: 'test_notification_' + Date.now(),
requireInteraction: false,
});
console.log('浏览器通知创建成功:', notification);
// 监听通知显示(成功显示)
notification.onshow = () => {
console.log('✅ 浏览器通知已显示onshow 事件触发)');
setNotificationShown(true);
};
// 监听通知错误
notification.onerror = (error) => {
console.error('❌ 浏览器通知错误:', error);
setNotificationShown(false);
};
// 监听通知关闭
notification.onclose = () => {
console.log('浏览器通知已关闭');
};
// 8秒后自动关闭
setTimeout(() => {
notification.close();
console.log('浏览器通知已自动关闭');
}, 8000);
// 点击通知时聚焦窗口
notification.onclick = () => {
console.log('浏览器通知被点击');
window.focus();
notification.close();
setNotificationShown(true);
};
setTestCount(prev => prev + 1);
} catch (error) {
console.error('创建浏览器通知失败:', error);
alert('创建浏览器通知失败:' + error.message);
setIsTestingNotification(false);
setNotificationShown(false);
}
}}
isLoading={isTestingNotification}
loadingText={`等待通知... ${testCountdown}s`}
>
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
浏览器通知已启用
</Text>
)}
{browserPermission === 'denied' && (
<Text fontSize="xs" color="red.500">
请在浏览器设置中允许通知
</Text>
)}
{/* 实时权限状态 */}
<HStack spacing={2} justify="center">
<Text fontSize="xs" color="gray.500">
实际权限
</Text>
<Badge
colorScheme={
('Notification' in window && Notification.permission === 'granted') ? 'green' :
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
}
>
{('Notification' in window) ? Notification.permission : '不支持'}
</Badge>
</HStack>
{/* 环境警告 */}
{isFullscreen && (
<Alert status="warning" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">全屏模式</Text>
<Text>某些浏览器在全屏模式下不显示通知</Text>
</Box>
</Alert>
)}
{isMacOS && notificationShown === false && (
<Alert status="error" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">未检测到通知显示</Text>
<Text>可能是专注模式阻止了通知</Text>
</Box>
</Alert>
)}
<Divider />
{/* 故障排查面板 */}
<VStack spacing={2} align="stretch">
<Button
size="sm"
variant="outline"
colorScheme="orange"
leftIcon={<MdWarning />}
onClick={onTroubleshootToggle}
>
{isTroubleshootOpen ? '收起' : '故障排查指南'}
</Button>
<Collapse in={isTroubleshootOpen} animateOpacity>
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
<Text fontSize="xs" fontWeight="bold" color="orange.800">
如果看不到浏览器通知请检查
</Text>
{/* macOS 专注模式 */}
{isMacOS && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>点击右上角控制中心</ListItem>
<ListItem>关闭专注模式勿扰模式</ListItem>
<ListItem>或者系统设置 专注模式 关闭</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* macOS 系统通知设置 */}
{isMacOS && (
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>系统设置 通知</ListItem>
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> <Code fontSize="xs">Microsoft Edge</Code></ListItem>
<ListItem>确保允许通知已开启</ListItem>
<ListItem>通知样式设置为横幅提醒</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* Chrome 浏览器设置 */}
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
<ListItem>确保网站可以请求发送通知已开启</ListItem>
<ListItem>检查本站点是否在允许列表中</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
{/* 全屏模式提示 */}
{isFullscreen && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
<AlertDescription>
<Code fontSize="xs">ESC</Code> 退
</AlertDescription>
</Box>
</Alert>
)}
{/* 测试结果反馈 */}
{notificationShown === true && (
<Alert status="success" size="sm">
<AlertIcon />
<Text fontSize="xs"> 通知功能正常</Text>
</Alert>
)}
</VStack>
</Collapse>
</VStack>
<Divider />
{/* 功能按钮 */}
<HStack spacing={2}>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={clearAllNotifications}
flex={1}
>
清空全部
</Button>
<IconButton
size="sm"
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
colorScheme={soundEnabled ? 'blue' : 'gray'}
onClick={toggleSound}
aria-label="切换音效"
/>
</HStack>
{/* 统计信息 */}
<VStack spacing={1}>
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
当前队列:
</Text>
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
{notifications.length} / 5
</Badge>
</HStack>
<Text fontSize="xs" color="gray.400" textAlign="center">
已测试: {testCount} 条通知
</Text>
</VStack>
</VStack>
</Collapse>
</Box>
);
};
export default NotificationTestTool;

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

@@ -353,13 +353,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知 * 发送浏览器通知
*/ */
const sendBrowserNotification = useCallback((notificationData) => { const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用'); logger.debug('NotificationContext', 'sendBrowserNotification 被调用', {
console.log('[NotificationContext] 通知数据:', notificationData); notificationData,
console.log('[NotificationContext] 当前浏览器权限:', browserPermission); browserPermission
});
if (browserPermission !== 'granted') { if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted'); logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
return; return;
} }
@@ -371,7 +371,7 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭) // 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT; const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', { logger.debug('NotificationContext', '准备发送浏览器通知', {
title, title,
body: content, body: content,
tag, tag,
@@ -390,12 +390,12 @@ export const NotificationProvider = ({ children }) => {
}); });
if (notification) { if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification); logger.info('NotificationContext', '通知对象创建成功', { notification });
// 设置点击处理(聚焦窗口并跳转) // 设置点击处理(聚焦窗口并跳转)
if (link) { if (link) {
notification.onclick = () => { notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link); logger.info('NotificationContext', '通知被点击,跳转到', { link });
window.focus(); window.focus();
// 使用 window.location 跳转(不需要 React Router // 使用 window.location 跳转(不需要 React Router
window.location.hash = link; window.location.hash = link;
@@ -405,7 +405,7 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Browser notification sent', { title, tag }); logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else { } else {
console.error('[NotificationContext] ❌ 通知对象创建失败!'); logger.error('NotificationContext', '通知对象创建失败');
} }
}, [browserPermission]); }, [browserPermission]);
@@ -640,19 +640,18 @@ export const NotificationProvider = ({ children }) => {
*/ */
useEffect(() => { useEffect(() => {
addNotificationRef.current = addNotification; addNotificationRef.current = addNotification;
console.log('[NotificationContext] 📝 已更新 addNotificationRef'); logger.debug('NotificationContext', '已更新 addNotificationRef');
}, [addNotification]); }, [addNotification]);
useEffect(() => { useEffect(() => {
adaptEventToNotificationRef.current = adaptEventToNotification; adaptEventToNotificationRef.current = adaptEventToNotification;
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef'); logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef');
}, [adaptEventToNotification]); }, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ========== // ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
useEffect(() => { useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...'); logger.info('NotificationContext', '初始化 Socket 连接方案2只注册一次');
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接方案2只注册一次', 'color: #673AB7; font-weight: bold;');
// ========== 监听连接成功(首次连接 + 重连) ========== // ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => { socket.on('connect', () => {
@@ -661,15 +660,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是首次连接还是重连 // 判断是首次连接还是重连
if (isFirstConnect.current) { if (isFirstConnect.current) {
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;'); logger.info('NotificationContext', '首次连接成功', {
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.()); socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED); setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false; isFirstConnect.current = false;
logger.info('NotificationContext', 'Socket connected (first time)');
} else { } else {
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;'); logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED); setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
logger.info('NotificationContext', 'Socket reconnected');
// 清除之前的定时器 // 清除之前的定时器
if (reconnectedTimerRef.current) { if (reconnectedTimerRef.current) {
@@ -684,20 +682,18 @@ export const NotificationProvider = ({ children }) => {
} }
// ⚡ 重连后只需重新订阅,不需要重新注册监听器 // ⚡ 重连后只需重新订阅,不需要重新注册监听器
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;'); logger.info('NotificationContext', '重新订阅事件推送');
if (socket.subscribeToEvents) { if (socket.subscribeToEvents) {
socket.subscribeToEvents({ socket.subscribeToEvents({
eventType: 'all', eventType: 'all',
importance: 'all', importance: 'all',
onSubscribed: (data) => { onSubscribed: (data) => {
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;'); logger.info('NotificationContext', '订阅成功', data);
console.log('[NotificationContext] 订阅确认:', data);
logger.info('NotificationContext', 'Events subscribed', data);
}, },
}); });
} else { } else {
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用'); logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
} }
}); });
@@ -705,8 +701,7 @@ export const NotificationProvider = ({ children }) => {
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
setIsConnected(false); setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED); setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason }); logger.warn('NotificationContext', 'Socket 已断开', { reason });
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
}); });
// ========== 监听连接错误 ========== // ========== 监听连接错误 ==========
@@ -716,15 +711,13 @@ export const NotificationProvider = ({ children }) => {
const attempts = socket.getReconnectAttempts?.() || 0; const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts); setReconnectAttempt(attempts);
logger.info('NotificationContext', 'Reconnection attempt', { attempts }); logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
}); });
// ========== 监听重连失败 ========== // ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => { socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed'); logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED); setConnectionStatus(CONNECTION_STATUS.FAILED);
console.error('[NotificationContext] ❌ 重连失败');
toast({ toast({
title: '连接失败', title: '连接失败',
@@ -737,21 +730,17 @@ export const NotificationProvider = ({ children }) => {
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ========== // ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => { socket.on('new_event', (data) => {
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;'); logger.info('NotificationContext', '收到 new_event 事件', {
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;'); id: data?.id,
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;'); title: data?.title,
console.log('[NotificationContext] 原始事件数据:', data); eventType: data?.event_type || data?.type,
console.log('[NotificationContext] 事件 ID:', data?.id); importance: data?.importance
console.log('[NotificationContext] 事件标题:', data?.title); });
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type); logger.debug('NotificationContext', '原始事件数据', data);
console.log('[NotificationContext] 事件重要性:', data?.importance);
logger.info('NotificationContext', 'Received new event', data);
// ⚠️ 防御性检查:确保 ref 已初始化 // ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) { if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;'); logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
logger.error('NotificationContext', 'Refs not initialized', {
addNotificationRef: !!addNotificationRef.current, addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current, adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
}); });
@@ -770,14 +759,12 @@ export const NotificationProvider = ({ children }) => {
} }
if (processedEventIds.current.has(eventId)) { if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId }); logger.warn('NotificationContext', '重复事件已忽略', { eventId });
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
return; return;
} }
processedEventIds.current.add(eventId); processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理'); logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏 // 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) { if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
@@ -790,45 +777,41 @@ export const NotificationProvider = ({ children }) => {
// ========== Socket层去重检查结束 ========== // ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱) // ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
console.log('[NotificationContext] 正在转换事件格式...'); logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data); const notification = adaptEventToNotificationRef.current(data);
console.log('[NotificationContext] 转换后的通知对象:', notification); logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数 // ✅ 使用 ref.current 访问最新的 addNotification 函数
console.log('[NotificationContext] 准备添加通知到队列...'); logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification); addNotificationRef.current(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列'); logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据) // ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) { if (eventUpdateCallbacks.current.size > 0) {
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`); logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => { eventUpdateCallbacks.current.forEach(callback => {
try { try {
callback(data); callback(data);
} catch (error) { } catch (error) {
logger.error('NotificationContext', 'Event update callback error', error); logger.error('NotificationContext', '事件更新回调执行失败', error);
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
} }
}); });
console.log('[NotificationContext] ✅ 所有事件更新回调已触发'); logger.debug('NotificationContext', '所有事件更新回调已触发');
} }
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
}); });
// ========== 监听系统通知(兼容性) ========== // ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => { socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data); logger.info('NotificationContext', '收到系统通知', data);
console.log('[NotificationContext] 📢 收到系统通知:', data);
if (addNotificationRef.current) { if (addNotificationRef.current) {
addNotificationRef.current(data); addNotificationRef.current(data);
} else { } else {
console.error('[NotificationContext] ❌ addNotificationRef 未初始化'); logger.error('NotificationContext', 'addNotificationRef 未初始化');
} }
}); });
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;'); logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
// ========== 获取最大重连次数 ========== // ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity; const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
@@ -836,13 +819,12 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts }); logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ========== 启动连接 ========== // ========== 启动连接 ==========
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;'); logger.info('NotificationContext', '调用 socket.connect()');
socket.connect(); socket.connect();
// ========== 清理函数(组件卸载时) ========== // ========== 清理函数(组件卸载时) ==========
return () => { return () => {
logger.info('NotificationContext', 'Cleaning up socket connection'); logger.info('NotificationContext', '清理 Socket 连接');
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
// 清理 reconnected 状态定时器 // 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) { if (reconnectedTimerRef.current) {
@@ -868,7 +850,7 @@ export const NotificationProvider = ({ children }) => {
// 断开连接 // 断开连接
socket.disconnect(); socket.disconnect();
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;'); logger.info('NotificationContext', '清理完成');
}; };
}, []); // ⚠️ 空依赖数组,确保只执行一次 }, []); // ⚠️ 空依赖数组,确保只执行一次
@@ -984,92 +966,6 @@ export const NotificationProvider = ({ children }) => {
}; };
}, [browserPermission, toast]); }, [browserPermission, toast]);
// 🔧 开发环境调试:暴露方法到 window
useEffect(() => {
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
if (typeof window !== 'undefined') {
window.__TEST_NOTIFICATION__ = {
// 手动触发网页通知
testWebNotification: (type = 'event_alert', priority = 'normal') => {
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
const testData = {
id: `test_${Date.now()}`,
type: type,
priority: priority,
title: '🧪 测试网页通知',
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
timestamp: Date.now(),
clickable: true,
link: '/home',
};
console.log('测试数据:', testData);
addNotification(testData);
console.log('✅ 通知已添加到队列');
},
// 测试所有类型
testAllTypes: () => {
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
types.forEach((type, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
}, i * 2000); // 每 2 秒一个
});
},
// 测试所有优先级
testAllPriorities: () => {
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
const priorities = ['normal', 'important', 'urgent'];
priorities.forEach((priority, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
}, i * 2000);
});
},
// 帮助
help: () => {
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
console.log(' type (通知类型):');
console.log(' - "announcement" 公告通知(蓝色)');
console.log(' - "stock_alert" 股票动向(红色/绿色)');
console.log(' - "event_alert" 事件动向(橙色)');
console.log(' - "analysis_report" 分析报告(紫色)');
console.log('\n priority (优先级):');
console.log(' - "normal" 普通15秒自动关闭');
console.log(' - "important" 重要30秒自动关闭');
console.log(' - "urgent" 紧急(不自动关闭)');
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
console.log(' // 测试紧急事件通知');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
console.log('\n // 测试所有类型');
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
console.log('\n // 测试所有优先级');
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
console.log('\n');
}
};
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
}
}
// 清理函数
return () => {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
delete window.__TEST_NOTIFICATION__;
}
};
}, [addNotification]); // 依赖 addNotification 函数
const value = { const value = {
notifications, notifications,
isConnected, isConnected,

View File

@@ -10,20 +10,17 @@
* 全局 API: * 全局 API:
* - window.__DEBUG__ - 调试 API 主对象 * - window.__DEBUG__ - 调试 API 主对象
* - window.__DEBUG__.api - API 调试工具 * - window.__DEBUG__.api - API 调试工具
* - window.__DEBUG__.notification - 通知调试工具
* - window.__DEBUG__.socket - Socket 调试工具 * - window.__DEBUG__.socket - Socket 调试工具
* - window.__DEBUG__.help() - 显示帮助信息 * - window.__DEBUG__.help() - 显示帮助信息
* - window.__DEBUG__.exportAll() - 导出所有日志 * - window.__DEBUG__.exportAll() - 导出所有日志
*/ */
import { apiDebugger } from './apiDebugger'; import { apiDebugger } from './apiDebugger';
import { notificationDebugger } from './notificationDebugger';
import { socketDebugger } from './socketDebugger'; import { socketDebugger } from './socketDebugger';
class DebugToolkit { class DebugToolkit {
constructor() { constructor() {
this.api = apiDebugger; this.api = apiDebugger;
this.notification = notificationDebugger;
this.socket = socketDebugger; this.socket = socketDebugger;
} }
@@ -47,7 +44,6 @@ class DebugToolkit {
// 初始化各个调试工具 // 初始化各个调试工具
this.api.init(); this.api.init();
this.notification.init();
this.socket.init(); this.socket.init();
// 暴露到全局 // 暴露到全局
@@ -69,22 +65,13 @@ class DebugToolkit {
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志'); console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求'); console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
console.log(''); console.log('');
console.log('%c2通知调试:', 'color: #9C27B0; font-weight: bold;'); console.log('%c2Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
console.log('');
console.log('%c3⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志'); console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态'); console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
console.log(' __DEBUG__.socket.reconnect() - 手动重连'); console.log(' __DEBUG__.socket.reconnect() - 手动重连');
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志'); console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
console.log(''); console.log('');
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;'); console.log('%c3️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
console.log(' __DEBUG__.help() - 显示帮助信息'); console.log(' __DEBUG__.help() - 显示帮助信息');
console.log(' __DEBUG__.exportAll() - 导出所有日志'); console.log(' __DEBUG__.exportAll() - 导出所有日志');
console.log(' __DEBUG__.printStats() - 打印所有统计信息'); console.log(' __DEBUG__.printStats() - 打印所有统计信息');
@@ -113,7 +100,6 @@ class DebugToolkit {
const allLogs = { const allLogs = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
api: this.api.getLogs(), api: this.api.getLogs(),
notification: this.notification.getLogs(),
socket: this.socket.getLogs(), socket: this.socket.getLogs(),
}; };
@@ -138,15 +124,11 @@ class DebugToolkit {
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;'); console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
const apiStats = this.api.printStats(); const apiStats = this.api.printStats();
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
const notificationStats = this.notification.printStats();
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;'); console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
const socketStats = this.socket.printStats(); const socketStats = this.socket.printStats();
return { return {
api: apiStats, api: apiStats,
notification: notificationStats,
socket: socketStats, socket: socketStats,
}; };
} }
@@ -157,7 +139,6 @@ class DebugToolkit {
clearAll() { clearAll() {
console.log('[Debug Toolkit] Clearing all logs...'); console.log('[Debug Toolkit] Clearing all logs...');
this.api.clearLogs(); this.api.clearLogs();
this.notification.clearLogs();
this.socket.clearLogs(); this.socket.clearLogs();
console.log('[Debug Toolkit] ✅ All logs cleared'); console.log('[Debug Toolkit] ✅ All logs cleared');
} }
@@ -169,15 +150,11 @@ class DebugToolkit {
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;'); console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
// 1. Socket 状态 // 1. Socket 状态
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;'); console.log('\n%c[1/2] Socket 状态', 'color: #00BCD4; font-weight: bold;');
const socketStatus = this.socket.getStatus(); const socketStatus = this.socket.getStatus();
// 2. 通知权限 // 2. API 错误
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;'); console.log('\n%c[2/2] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
const notificationStatus = this.notification.checkPermission();
// 3. API 错误
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
const recentErrors = this.api.getRecentErrors(5); const recentErrors = this.api.getRecentErrors(5);
if (recentErrors.length > 0) { if (recentErrors.length > 0) {
console.table( console.table(
@@ -193,11 +170,10 @@ class DebugToolkit {
console.log('✅ 没有 API 错误'); console.log('✅ 没有 API 错误');
} }
// 4. 汇总报告 // 3. 汇总报告
const report = { const report = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
socket: socketStatus, socket: socketStatus,
notification: notificationStatus,
apiErrors: recentErrors.length, apiErrors: recentErrors.length,
}; };

View File

@@ -1,204 +0,0 @@
// src/debug/notificationDebugger.js
/**
* 通知系统调试工具
* 扩展现有的 window.__NOTIFY_DEBUG__添加更多生产环境调试能力
*/
import { browserNotificationService } from '@services/browserNotificationService';
class NotificationDebugger {
constructor() {
this.eventLog = [];
this.maxLogSize = 100;
}
/**
* 初始化调试工具
*/
init() {
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
}
/**
* 记录通知事件
*/
logEvent(eventType, data) {
const logEntry = {
type: eventType,
timestamp: new Date().toISOString(),
data,
};
this.eventLog.unshift(logEntry);
if (this.eventLog.length > this.maxLogSize) {
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
}
console.log(
`%c[Notification Event] ${eventType}`,
'color: #9C27B0; font-weight: bold;',
data
);
}
/**
* 获取所有事件日志
*/
getLogs() {
return this.eventLog;
}
/**
* 清空日志
*/
clearLogs() {
this.eventLog = [];
console.log('[Notification Debugger] Logs cleared');
}
/**
* 导出日志
*/
exportLogs() {
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[Notification Debugger] Logs exported');
}
/**
* 强制发送浏览器通知(测试用)
*/
forceNotification(options = {}) {
const defaultOptions = {
title: '🧪 测试通知',
body: `测试时间: ${new Date().toLocaleString()}`,
tag: `test_${Date.now()}`,
requireInteraction: false,
autoClose: 5000,
};
const finalOptions = { ...defaultOptions, ...options };
console.log('[Notification Debugger] Sending test notification:', finalOptions);
const notification = browserNotificationService.sendNotification(finalOptions);
if (notification) {
console.log('[Notification Debugger] ✅ Notification sent successfully');
} else {
console.error('[Notification Debugger] ❌ Failed to send notification');
}
return notification;
}
/**
* 检查通知权限状态
*/
checkPermission() {
const permission = browserNotificationService.getPermissionStatus();
const isSupported = browserNotificationService.isSupported();
const status = {
supported: isSupported,
permission,
canSend: isSupported && permission === 'granted',
};
console.table(status);
return status;
}
/**
* 请求通知权限
*/
async requestPermission() {
console.log('[Notification Debugger] Requesting notification permission...');
const result = await browserNotificationService.requestPermission();
console.log(`[Notification Debugger] Permission result: ${result}`);
return result;
}
/**
* 打印事件统计
*/
printStats() {
const stats = {
total: this.eventLog.length,
byType: {},
};
this.eventLog.forEach((log) => {
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
});
console.log('=== Notification Stats ===');
console.table(stats.byType);
console.log(`Total events: ${stats.total}`);
return stats;
}
/**
* 按类型过滤日志
*/
getLogsByType(eventType) {
return this.eventLog.filter((log) => log.type === eventType);
}
/**
* 获取最近的事件
*/
getRecentEvents(count = 10) {
return this.eventLog.slice(0, count);
}
/**
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
*/
testWebNotification(type = 'event_alert', priority = 'normal') {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
console.log('[Notification Debugger] 调用测试 API');
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
console.error('💡 请确保:');
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
console.error(' 2. NotificationContext 已加载');
console.error(' 3. 页面已刷新');
}
}
/**
* 测试所有通知类型
*/
testAllNotificationTypes() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllTypes();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
/**
* 测试所有优先级
*/
testAllNotificationPriorities() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllPriorities();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
}
// 导出单例
export const notificationDebugger = new NotificationDebugger();
export default notificationDebugger;

View File

@@ -120,7 +120,7 @@ export function usePagination<T>(
loadData(1, false); loadData(1, false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoLoad]); }, [autoLoad, loadFunction]);
return { return {
data, data,

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

@@ -38,6 +38,10 @@ export const lazyComponents = {
// Agent模块 // Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')), AgentChat: React.lazy(() => import('../views/AgentChat')),
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
}; };
/** /**
@@ -63,4 +67,6 @@ export const {
FinancialPanorama, FinancialPanorama,
MarketDataView, MarketDataView,
AgentChat, AgentChat,
ValueForum,
ForumPostDetail,
} = lazyComponents; } = lazyComponents;

View File

@@ -150,6 +150,28 @@ export const routeConfig = [
} }
}, },
// ==================== 价值论坛模块 ====================
{
path: 'value-forum',
component: lazyComponents.ValueForum,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '价值论坛',
description: '投资者价值讨论社区'
}
},
{
path: 'value-forum/post/:postId',
component: lazyComponents.ForumPostDetail,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '帖子详情',
description: '论坛帖子详细内容'
}
},
// ==================== Agent模块 ==================== // ==================== Agent模块 ====================
{ {
path: 'agent-chat', path: 'agent-chat',

View File

@@ -144,8 +144,8 @@ export const WECHAT_STATUS = {
WAITING: 'waiting', WAITING: 'waiting',
SCANNED: 'scanned', SCANNED: 'scanned',
AUTHORIZED: 'authorized', AUTHORIZED: 'authorized',
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized' LOGIN_SUCCESS: 'login_ready', // ✅ 修复:与后端返回的状态一致
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized' REGISTER_SUCCESS: 'register_ready', // ✅ 修复:与后端返回的状态一致
EXPIRED: 'expired', EXPIRED: 'expired',
AUTH_DENIED: 'auth_denied', // 用户拒绝授权 AUTH_DENIED: 'auth_denied', // 用户拒绝授权
AUTH_FAILED: 'auth_failed', // 授权失败 AUTH_FAILED: 'auth_failed', // 授权失败

View File

@@ -0,0 +1,442 @@
/**
* Elasticsearch 服务层
* 用于价值论坛的帖子、评论存储和搜索
*/
import axios from 'axios';
// Elasticsearch 配置
// 使用 Nginx 代理路径避免 Mixed Content 问题
const ES_CONFIG = {
baseURL: process.env.NODE_ENV === 'production'
? '/es-api' // 生产环境使用 Nginx 代理
: 'http://222.128.1.157:19200', // 开发环境直连
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
};
// 创建 axios 实例
const esClient = axios.create(ES_CONFIG);
// 索引名称
const INDICES = {
POSTS: 'forum_posts',
COMMENTS: 'forum_comments',
EVENTS: 'forum_events',
};
/**
* 初始化索引(创建索引和映射)
*/
export const initializeIndices = async () => {
try {
// 创建帖子索引
await esClient.put(`/${INDICES.POSTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
title: { type: 'text', analyzer: 'ik_max_word' },
content: { type: 'text', analyzer: 'ik_max_word' },
images: { type: 'keyword' },
tags: { type: 'keyword' },
category: { type: 'keyword' },
likes_count: { type: 'integer' },
comments_count: { type: 'integer' },
views_count: { type: 'integer' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
is_pinned: { type: 'boolean' },
status: { type: 'keyword' }, // active, deleted, hidden
},
},
});
// 创建评论索引
await esClient.put(`/${INDICES.COMMENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
content: { type: 'text', analyzer: 'ik_max_word' },
parent_id: { type: 'keyword' }, // 用于嵌套评论
likes_count: { type: 'integer' },
created_at: { type: 'date' },
status: { type: 'keyword' },
},
},
});
// 创建事件时间轴索引
await esClient.put(`/${INDICES.EVENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
title: { type: 'text' },
description: { type: 'text', analyzer: 'ik_max_word' },
source: { type: 'keyword' },
source_url: { type: 'keyword' },
related_stocks: { type: 'keyword' },
occurred_at: { type: 'date' },
created_at: { type: 'date' },
importance: { type: 'keyword' }, // high, medium, low
},
},
});
console.log('Elasticsearch 索引初始化成功');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('索引已存在,跳过创建');
} else {
console.error('初始化索引失败:', error);
throw error;
}
}
};
// ==================== 帖子相关操作 ====================
/**
* 创建新帖子
*/
export const createPost = async (postData) => {
try {
const post = {
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...postData,
likes_count: 0,
comments_count: 0,
views_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
is_pinned: false,
status: 'active',
};
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
return { ...post, _id: response.data._id };
} catch (error) {
console.error('创建帖子失败:', error);
throw error;
}
};
/**
* 获取帖子列表(支持分页、排序、筛选)
*/
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
try {
const from = (page - 1) * size;
const query = {
bool: {
must: [{ match: { status: 'active' } }],
},
};
if (category) {
query.bool.must.push({ term: { category } });
}
if (tags.length > 0) {
query.bool.must.push({ terms: { tags } });
}
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
from,
size,
query,
sort: [
{ is_pinned: { order: 'desc' } },
{ [sort]: { order } },
],
});
return {
total: response.data.hits.total.value,
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
page,
size,
};
} catch (error) {
console.error('获取帖子列表失败:', error);
throw error;
}
};
/**
* 获取单个帖子详情
*/
export const getPostById = async (postId) => {
try {
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
// 增加浏览量
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
script: {
source: 'ctx._source.views_count += 1',
lang: 'painless',
},
});
return { ...response.data._source, _id: response.data._id };
} catch (error) {
console.error('获取帖子详情失败:', error);
throw error;
}
};
/**
* 更新帖子
*/
export const updatePost = async (postId, updateData) => {
try {
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
doc: {
...updateData,
updated_at: new Date().toISOString(),
},
});
return response.data;
} catch (error) {
console.error('更新帖子失败:', error);
throw error;
}
};
/**
* 删除帖子(软删除)
*/
export const deletePost = async (postId) => {
try {
await updatePost(postId, { status: 'deleted' });
} catch (error) {
console.error('删除帖子失败:', error);
throw error;
}
};
/**
* 点赞帖子
*/
export const likePost = async (postId) => {
try {
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
script: {
source: 'ctx._source.likes_count += 1',
lang: 'painless',
},
});
} catch (error) {
console.error('点赞帖子失败:', error);
throw error;
}
};
/**
* 搜索帖子
*/
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
try {
const from = (page - 1) * size;
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
from,
size,
query: {
bool: {
must: [
{
multi_match: {
query: keyword,
fields: ['title^3', 'content', 'tags^2'],
type: 'best_fields',
},
},
{ match: { status: 'active' } },
],
},
},
highlight: {
fields: {
title: {},
content: {},
},
},
});
return {
total: response.data.hits.total.value,
posts: response.data.hits.hits.map((hit) => ({
...hit._source,
_id: hit._id,
highlight: hit.highlight,
})),
page,
size,
};
} catch (error) {
console.error('搜索帖子失败:', error);
throw error;
}
};
// ==================== 评论相关操作 ====================
/**
* 创建评论
*/
export const createComment = async (commentData) => {
try {
const comment = {
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...commentData,
likes_count: 0,
created_at: new Date().toISOString(),
status: 'active',
};
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
// 增加帖子评论数
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
script: {
source: 'ctx._source.comments_count += 1',
lang: 'painless',
},
});
return { ...comment, _id: response.data._id };
} catch (error) {
console.error('创建评论失败:', error);
throw error;
}
};
/**
* 获取帖子的评论列表
*/
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
try {
const from = (page - 1) * size;
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
from,
size,
query: {
bool: {
must: [
{ term: { post_id: postId } },
{ match: { status: 'active' } },
],
},
},
sort: [{ created_at: { order: 'asc' } }],
});
return {
total: response.data.hits.total.value,
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
};
} catch (error) {
// 如果索引不存在404返回空结果
if (error.response?.status === 404) {
console.warn('评论索引不存在,返回空结果:', INDICES.COMMENTS);
return { total: 0, comments: [] };
}
console.error('获取评论列表失败:', error);
throw error;
}
};
/**
* 点赞评论
*/
export const likeComment = async (commentId) => {
try {
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
script: {
source: 'ctx._source.likes_count += 1',
lang: 'painless',
},
});
} catch (error) {
console.error('点赞评论失败:', error);
throw error;
}
};
// ==================== 事件时间轴相关操作 ====================
/**
* 创建事件
*/
export const createEvent = async (eventData) => {
try {
const event = {
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...eventData,
created_at: new Date().toISOString(),
};
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
return { ...event, _id: response.data._id };
} catch (error) {
console.error('创建事件失败:', error);
throw error;
}
};
/**
* 获取帖子的事件时间轴
*/
export const getEventsByPostId = async (postId) => {
try {
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
size: 100,
query: {
term: { post_id: postId },
},
sort: [{ occurred_at: { order: 'desc' } }],
});
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
} catch (error) {
// 如果索引不存在404返回空数组而不是抛出错误
if (error.response?.status === 404) {
console.warn('事件索引不存在,返回空数组:', INDICES.EVENTS);
return [];
}
console.error('获取事件时间轴失败:', error);
throw error;
}
};
export default {
initializeIndices,
// 帖子操作
createPost,
getPosts,
getPostById,
updatePost,
deletePost,
likePost,
searchPosts,
// 评论操作
createComment,
getCommentsByPostId,
likeComment,
// 事件操作
createEvent,
getEventsByPostId,
};

View File

@@ -166,7 +166,27 @@ export const eventService = {
// 帖子相关API // 帖子相关API
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => { getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
try { try {
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`); const result = await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
// ⚡ 数据转换:将后端的 user 字段映射为前端期望的 author 字段
if (result.success && Array.isArray(result.data)) {
result.data = result.data.map(post => ({
...post,
author: post.user ? {
id: post.user.id,
username: post.user.username,
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
} : {
id: 'anonymous',
username: 'Anonymous',
avatar: null
}
// 保留原始的 user 字段(如果其他地方需要)
// user: post.user
}));
}
return result;
} catch (error) { } catch (error) {
logger.error('eventService', 'getPosts', error, { eventId, sortType, page }); logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
return { success: false, data: [], pagination: {} }; return { success: false, data: [], pagination: {} };

227
src/theme/forumTheme.js Normal file
View File

@@ -0,0 +1,227 @@
/**
* 价值论坛黑金主题配置
* 采用深色背景 + 金色点缀的高端配色方案
*/
export const forumColors = {
// 主色调 - 黑金渐变
primary: {
50: '#FFF9E6',
100: '#FFEEBA',
200: '#FFE38D',
300: '#FFD860',
400: '#FFCD33',
500: '#FFC107', // 主金色
600: '#FFB300',
700: '#FFA000',
800: '#FF8F00',
900: '#FF6F00',
},
// 背景色系 - 深黑渐变
background: {
main: '#0A0A0A', // 主背景 - 极黑
secondary: '#121212', // 次级背景
card: '#1A1A1A', // 卡片背景
hover: '#222222', // 悬停背景
elevated: '#2A2A2A', // 提升背景(模态框等)
},
// 文字色系
text: {
primary: '#FFFFFF', // 主文字 - 纯白
secondary: '#B8B8B8', // 次要文字 - 灰色
tertiary: '#808080', // 三级文字 - 深灰
muted: '#5A5A5A', // 弱化文字
gold: '#FFC107', // 金色强调文字
goldGradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', // 金色渐变
},
// 边框色系
border: {
default: '#333333',
light: '#404040',
gold: '#FFC107',
goldGlow: 'rgba(255, 193, 7, 0.3)',
},
// 功能色
semantic: {
success: '#4CAF50',
warning: '#FF9800',
error: '#F44336',
info: '#2196F3',
},
// 金色渐变系列
gradients: {
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
goldSecondary: 'linear-gradient(135deg, #FFC107 0%, #FF8F00 100%)',
goldSubtle: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
blackGold: 'linear-gradient(135deg, #0A0A0A 0%, #1A1A1A 50%, #2A2020 100%)',
cardHover: 'linear-gradient(135deg, #1A1A1A 0%, #252525 100%)',
},
// 阴影色系
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.5)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.4)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.7), 0 4px 6px -2px rgba(0, 0, 0, 0.5)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 10px 10px -5px rgba(0, 0, 0, 0.6)',
gold: '0 0 20px rgba(255, 193, 7, 0.3), 0 0 40px rgba(255, 193, 7, 0.1)',
goldHover: '0 0 30px rgba(255, 193, 7, 0.5), 0 0 60px rgba(255, 193, 7, 0.2)',
},
};
/**
* 论坛组件样式配置
*/
export const forumComponentStyles = {
// 按钮样式
Button: {
baseStyle: {
fontWeight: '600',
borderRadius: 'md',
transition: 'all 0.3s ease',
},
variants: {
gold: {
bg: forumColors.gradients.goldPrimary,
color: '#0A0A0A',
_hover: {
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
_disabled: {
transform: 'none',
},
},
_active: {
transform: 'translateY(0)',
},
},
goldOutline: {
bg: 'transparent',
color: forumColors.primary[500],
border: '2px solid',
borderColor: forumColors.primary[500],
_hover: {
bg: forumColors.gradients.goldSubtle,
boxShadow: forumColors.shadows.gold,
},
},
dark: {
bg: forumColors.background.card,
color: forumColors.text.primary,
border: '1px solid',
borderColor: forumColors.border.default,
_hover: {
bg: forumColors.background.hover,
borderColor: forumColors.border.light,
},
},
},
},
// 卡片样式
Card: {
baseStyle: {
container: {
bg: forumColors.background.card,
borderRadius: 'lg',
border: '1px solid',
borderColor: forumColors.border.default,
transition: 'all 0.3s ease',
_hover: {
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
transform: 'translateY(-4px)',
},
},
},
},
// 输入框样式
Input: {
variants: {
forum: {
field: {
bg: forumColors.background.secondary,
border: '1px solid',
borderColor: forumColors.border.default,
color: forumColors.text.primary,
_placeholder: {
color: forumColors.text.tertiary,
},
_hover: {
borderColor: forumColors.border.light,
},
_focus: {
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
},
},
},
},
},
// 标签样式
Tag: {
variants: {
gold: {
container: {
bg: forumColors.gradients.goldSubtle,
color: forumColors.primary[500],
border: '1px solid',
borderColor: forumColors.border.gold,
},
},
},
},
};
/**
* 论坛专用动画配置
*/
export const forumAnimations = {
fadeIn: {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.3 },
},
slideIn: {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
transition: { duration: 0.3 },
},
scaleIn: {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
transition: { duration: 0.2 },
},
goldGlow: {
animate: {
boxShadow: [
forumColors.shadows.gold,
forumColors.shadows.goldHover,
forumColors.shadows.gold,
],
},
transition: {
duration: 2,
repeat: Infinity,
repeatType: 'reverse',
},
},
};
export default {
colors: forumColors,
components: forumComponentStyles,
animations: forumAnimations,
};

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

@@ -1,7 +1,11 @@
// src/utils/logger.js // src/utils/logger.js
// 统一日志工具 // 统一日志工具
const isDevelopment = process.env.NODE_ENV === 'development'; // 支持开发环境或显式开启调试模式
// 生产环境下可以通过设置 REACT_APP_ENABLE_DEBUG=true 来开启调试日志
const isDevelopment =
process.env.NODE_ENV === 'development' ||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
// ========== 日志限流配置 ========== // ========== 日志限流配置 ==========
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次 const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次

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

@@ -63,7 +63,7 @@ let dynamicNewsCardRenderCount = 0;
* @param {Object} trackingFunctions - PostHog 追踪函数集合 * @param {Object} trackingFunctions - PostHog 追踪函数集合
* @param {Object} ref - 用于滚动的ref * @param {Object} ref - 用于滚动的ref
*/ */
const DynamicNewsCard = forwardRef(({ const DynamicNewsCardComponent = forwardRef(({
filters = {}, filters = {},
popularKeywords = [], popularKeywords = [],
lastUpdateTime, lastUpdateTime,
@@ -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;', {
@@ -227,8 +236,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
// ========== 纵向模式 ========== // ========== 纵向模式 ==========
// 只在第1页时刷新避免打断用户浏览其他页 // 只在第1页时刷新避免打断用户浏览其他页
if (state.currentPage === 1) { if (state.currentPage === 1) {
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表'); console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表');
handlePageChange(1); // 清空缓存并刷新第1页 handlePageChange(1, true); // ⚡ 传递 force = true强制刷新第1页
toast({ toast({
title: '检测到新事件', title: '检测到新事件',
status: 'info', status: 'info',
@@ -722,6 +731,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
); );
}); });
DynamicNewsCard.displayName = 'DynamicNewsCard'; DynamicNewsCardComponent.displayName = 'DynamicNewsCard';
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
const DynamicNewsCard = React.memo(DynamicNewsCardComponent);
export default DynamicNewsCard; export default DynamicNewsCard;

View File

@@ -30,7 +30,7 @@ import VerticalModeLayout from './VerticalModeLayout';
* @param {Function} onToggleFollow - 关注按钮回调 * @param {Function} onToggleFollow - 关注按钮回调
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref用于获取滚动位置 * @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref用于获取滚动位置
*/ */
const EventScrollList = ({ const EventScrollList = React.memo(({
events, events,
displayEvents, displayEvents,
loadNextPage, loadNextPage,
@@ -144,6 +144,6 @@ const EventScrollList = ({
/> />
</Box> </Box>
); );
}; });
export default EventScrollList; export default EventScrollList;

View File

@@ -9,7 +9,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
* @param {string} mode - 当前模式 'vertical' | 'four-row' * @param {string} mode - 当前模式 'vertical' | 'four-row'
* @param {Function} onModeChange - 模式切换回调 * @param {Function} onModeChange - 模式切换回调
*/ */
const ModeToggleButtons = ({ mode, onModeChange }) => { const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
return ( return (
<ButtonGroup size="sm" isAttached> <ButtonGroup size="sm" isAttached>
<Button <Button
@@ -28,6 +28,6 @@ const ModeToggleButtons = ({ mode, onModeChange }) => {
</Button> </Button>
</ButtonGroup> </ButtonGroup>
); );
}; });
export default ModeToggleButtons; export default ModeToggleButtons;

View File

@@ -35,7 +35,7 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数 * @param {Function} getTimelineBoxStyle - 时间线样式获取函数
* @param {string} borderColor - 边框颜色 * @param {string} borderColor - 边框颜色
*/ */
const VerticalModeLayout = ({ const VerticalModeLayout = React.memo(({
display = 'flex', display = 'flex',
events, events,
selectedEvent, selectedEvent,
@@ -182,6 +182,6 @@ const VerticalModeLayout = ({
)} )}
</Flex> </Flex>
); );
}; });
export default VerticalModeLayout; export default VerticalModeLayout;

View File

@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
* @param {boolean} props.hasMore - 是否还有更多数据 * @param {boolean} props.hasMore - 是否还有更多数据
* @param {boolean} props.loading - 加载状态 * @param {boolean} props.loading - 加载状态
*/ */
const VirtualizedFourRowGrid = forwardRef(({ const VirtualizedFourRowGridComponent = forwardRef(({
display = 'block', display = 'block',
events, events,
columnsPerRow = 4, columnsPerRow = 4,
@@ -387,6 +387,9 @@ const VirtualizedFourRowGrid = forwardRef(({
); );
}); });
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid'; VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid';
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent);
export default VirtualizedFourRowGrid; export default VirtualizedFourRowGrid;

View File

@@ -158,7 +158,11 @@ export const usePagination = ({
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值 }, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
// 翻页处理第1页强制刷新 + 其他页缓存) // 翻页处理第1页强制刷新 + 其他页缓存)
const handlePageChange = useCallback(async (newPage) => { const handlePageChange = useCallback(async (newPage, force = false) => {
// force 参数:是否强制刷新(绕过"重复点击"检查)
// - true: 强制刷新Socket 新事件触发)
// - false: 正常翻页(用户点击分页按钮)
// 边界检查 1: 检查页码范围 // 边界检查 1: 检查页码范围
if (newPage < 1 || newPage > totalPages) { if (newPage < 1 || newPage > totalPages) {
console.log(`%c⚠ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;'); console.log(`%c⚠ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
@@ -166,13 +170,19 @@ export const usePagination = ({
return; return;
} }
// 边界检查 2: 检查是否重复点击 // 边界检查 2: 检查是否重复点击(强制刷新时绕过此检查)
if (newPage === currentPage) { if (!force && newPage === currentPage) {
console.log(`%c⚠ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;'); console.log(`%c⚠ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
logger.debug('usePagination', '页码未改变', { newPage }); logger.debug('usePagination', '页码未改变', { newPage });
return; return;
} }
// ⚡ 如果是强制刷新force = true即使页码相同也继续执行
if (force && newPage === currentPage) {
console.log(`%c🔄 [翻页] 强制刷新当前页: ${newPage}`, 'color: #10B981; font-weight: bold;');
logger.info('usePagination', '强制刷新当前页', { newPage });
}
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求 // 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
if (loadingPage === newPage) { if (loadingPage === newPage) {
console.log(`%c⚠ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;'); console.log(`%c⚠ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; 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';
@@ -33,7 +33,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
* @param {Function} props.onToggleFollow - 切换关注事件 * @param {Function} props.onToggleFollow - 切换关注事件
* @param {string} props.borderColor - 边框颜色 * @param {string} props.borderColor - 边框颜色
*/ */
const DynamicNewsEventCard = ({ const DynamicNewsEventCard = React.memo(({
event, event,
index, index,
isFollowing, isFollowing,
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = ({
* @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 = ({
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 && (
<> <>
{' • '} {' • '}
@@ -317,6 +317,6 @@ const DynamicNewsEventCard = ({
</Card> </Card>
</VStack> </VStack>
); );
}; });
export default DynamicNewsEventCard; export default DynamicNewsEventCard;

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

@@ -39,7 +39,7 @@ import KeywordsCarousel from './KeywordsCarousel';
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large') * @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度 * @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
*/ */
const HorizontalDynamicNewsEventCard = ({ const HorizontalDynamicNewsEventCard = React.memo(({
event, event,
index, index,
isFollowing, isFollowing,
@@ -227,6 +227,6 @@ const HorizontalDynamicNewsEventCard = ({
</Box> </Box>
</HStack> </HStack>
); );
}; });
export default HorizontalDynamicNewsEventCard; export default HorizontalDynamicNewsEventCard;

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

@@ -102,7 +102,7 @@ export default function CenterDashboard() {
const [w, e, c] = await Promise.all([ const [w, e, c] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }), fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }), fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/comments?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }), fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
]); ]);
const jw = await w.json(); const jw = await w.json();
const je = await e.json(); const je = await e.json();

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>

View File

@@ -0,0 +1,370 @@
/**
* 帖子详情页
* 展示帖子完整内容、事件时间轴、评论区
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
HStack,
VStack,
Avatar,
Badge,
Button,
Image,
SimpleGrid,
Spinner,
Center,
Flex,
IconButton,
Divider,
} from '@chakra-ui/react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
ArrowLeft,
Heart,
MessageCircle,
Eye,
Share2,
Bookmark,
} from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
getPostById,
likePost,
getEventsByPostId,
} from '@services/elasticsearchService';
import EventTimeline from './components/EventTimeline';
import CommentSection from './components/CommentSection';
const MotionBox = motion(Box);
const PostDetail = () => {
const { postId } = useParams();
const navigate = useNavigate();
const [post, setPost] = useState(null);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(0);
// 加载帖子数据
useEffect(() => {
const loadPostData = async () => {
try {
setLoading(true);
// 并行加载帖子和事件
const [postData, eventsData] = await Promise.all([
getPostById(postId),
getEventsByPostId(postId),
]);
setPost(postData);
setLikes(postData.likes_count || 0);
setEvents(eventsData);
} catch (error) {
console.error('加载帖子失败:', error);
} finally {
setLoading(false);
}
};
loadPostData();
}, [postId]);
// 处理点赞
const handleLike = async () => {
try {
if (!isLiked) {
await likePost(postId);
setLikes((prev) => prev + 1);
setIsLiked(true);
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
</Box>
);
}
if (!post) {
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
帖子不存在或已被删除
</Text>
<Button
leftIcon={<ArrowLeft size={18} />}
onClick={() => navigate('/value-forum')}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
>
返回论坛
</Button>
</VStack>
</Center>
</Box>
);
}
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
<Container maxW="container.xl">
{/* 返回按钮 */}
<Button
leftIcon={<ArrowLeft size={18} />}
onClick={() => navigate('/value-forum')}
variant="ghost"
color={forumColors.text.secondary}
_hover={{ color: forumColors.text.primary }}
mb="6"
>
返回论坛
</Button>
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing="6">
{/* 左侧:帖子内容 + 评论 */}
<Box gridColumn={{ base: '1', lg: '1 / 3' }}>
<VStack spacing="6" align="stretch">
{/* 帖子主体 */}
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
overflow="hidden"
>
{/* 作者信息 */}
<Box p="6" borderBottomWidth="1px" borderColor={forumColors.border.default}>
<Flex justify="space-between" align="start">
<HStack spacing="3">
<Avatar
size="md"
name={post.author_name}
src={post.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<VStack align="start" spacing="1">
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary}>
{post.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.muted}>
发布于 {formatTime(post.created_at)}
</Text>
</VStack>
</HStack>
{/* 操作按钮 */}
<HStack spacing="2">
<IconButton
icon={<Share2 size={18} />}
variant="ghost"
color={forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
aria-label="分享"
/>
<IconButton
icon={<Bookmark size={18} />}
variant="ghost"
color={forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
aria-label="收藏"
/>
</HStack>
</Flex>
</Box>
{/* 帖子内容 */}
<Box p="6">
{/* 标题 */}
<Heading
as="h1"
fontSize="2xl"
fontWeight="bold"
color={forumColors.text.primary}
mb="4"
>
{post.title}
</Heading>
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack spacing="2" mb="6" flexWrap="wrap">
{post.tags.map((tag, index) => (
<Badge
key={index}
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
fontSize="sm"
>
#{tag}
</Badge>
))}
</HStack>
)}
{/* 正文 */}
<Text
fontSize="md"
color={forumColors.text.secondary}
lineHeight="1.8"
whiteSpace="pre-wrap"
mb="6"
>
{post.content}
</Text>
{/* 图片 */}
{post.images && post.images.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing="4" mb="6">
{post.images.map((img, index) => (
<Image
key={index}
src={img}
alt={`图片 ${index + 1}`}
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
_hover={{
transform: 'scale(1.05)',
boxShadow: forumColors.shadows.gold,
}}
transition="all 0.3s"
/>
))}
</SimpleGrid>
)}
</Box>
{/* 互动栏 */}
<Box
p="4"
borderTopWidth="1px"
borderColor={forumColors.border.default}
bg={forumColors.background.secondary}
>
<Flex justify="space-between" align="center">
<HStack spacing="6">
<HStack
spacing="2"
cursor="pointer"
onClick={handleLike}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
>
<Heart size={20} fill={isLiked ? 'currentColor' : 'none'} />
<Text fontSize="sm" fontWeight="500">
{likes}
</Text>
</HStack>
<HStack spacing="2" color={forumColors.text.tertiary}>
<MessageCircle size={20} />
<Text fontSize="sm" fontWeight="500">
{post.comments_count || 0}
</Text>
</HStack>
<HStack spacing="2" color={forumColors.text.tertiary}>
<Eye size={20} />
<Text fontSize="sm" fontWeight="500">
{post.views_count || 0}
</Text>
</HStack>
</HStack>
<Button
leftIcon={<Heart size={18} />}
onClick={handleLike}
bg={isLiked ? forumColors.primary[500] : 'transparent'}
color={isLiked ? forumColors.background.main : forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.gold}
_hover={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
{isLiked ? '已点赞' : '点赞'}
</Button>
</Flex>
</Box>
</MotionBox>
{/* 评论区 */}
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<CommentSection postId={postId} />
</MotionBox>
</VStack>
</Box>
{/* 右侧:事件时间轴 */}
<Box gridColumn={{ base: '1', lg: '3' }}>
<MotionBox
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
position="sticky"
top="100px"
>
<EventTimeline events={events} />
</MotionBox>
</Box>
</SimpleGrid>
</Container>
</Box>
);
};
export default PostDetail;

View File

@@ -0,0 +1,318 @@
/**
* 评论区组件
* 支持发布评论、嵌套回复、点赞等功能
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
getCommentsByPostId,
createComment,
likeComment,
} from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, postId, onReply }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
if (!isLiked) {
await likeComment(comment.id);
setLikes((prev) => prev + 1);
setIsLiked(true);
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
};
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap="3" py="4">
{/* 头像 */}
<Avatar
size="sm"
name={comment.author_name}
src={comment.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing="2">
{/* 用户名和时间 */}
<HStack justify="space-between">
<HStack spacing="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{comment.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize="sm" color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 操作按钮 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<HStack
spacing="1"
cursor="pointer"
onClick={handleLike}
_hover={{ color: forumColors.primary[500] }}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
>
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
<Text>{likes > 0 ? likes : '点赞'}</Text>
</HStack>
<HStack
spacing="1"
cursor="pointer"
onClick={() => setShowReply(!showReply)}
_hover={{ color: forumColors.primary[500] }}
>
<MessageCircle size={14} />
<Text>回复</Text>
</HStack>
</HStack>
{/* 回复输入框 */}
{showReply && (
<MotionBox
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
mt="2"
>
<ReplyInput
postId={postId}
parentId={comment.id}
placeholder={`回复 @${comment.author_name}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ postId, parentId = null, placeholder, onSubmit }) => {
const toast = useToast();
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
toast({
title: '请输入评论内容',
status: 'warning',
duration: 2000,
});
return;
}
setIsSubmitting(true);
try {
await createComment({
post_id: postId,
parent_id: parentId,
content: content.trim(),
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
});
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Flex gap="2" align="end">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || '写下你的评论...'}
size="sm"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
minH="80px"
resize="vertical"
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h="40px"
/>
</Flex>
);
};
const CommentSection = ({ postId }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
// 加载评论
const loadComments = async () => {
try {
setLoading(true);
const result = await getCommentsByPostId(postId);
setComments(result.comments);
setTotal(result.total);
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [postId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize="lg" fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb="6">
<ReplyInput postId={postId} onSubmit={loadComments} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
加载中...
</Text>
) : comments.length === 0 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
暂无评论快来抢沙发吧
</Text>
) : (
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
<AnimatePresence>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
onReply={loadComments}
/>
))}
</AnimatePresence>
</VStack>
)}
</Box>
);
};
export default CommentSection;

View File

@@ -0,0 +1,419 @@
/**
* 发帖模态框组件
* 用于创建新帖子
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Input,
Textarea,
VStack,
HStack,
Text,
Box,
Image,
IconButton,
Tag,
TagLabel,
TagCloseButton,
useToast,
FormControl,
FormLabel,
FormErrorMessage,
} from '@chakra-ui/react';
import { ImagePlus, X, Hash } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { createPost } from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
const toast = useToast();
const { user } = useAuth();
const [formData, setFormData] = useState({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
const [currentTag, setCurrentTag] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = '请输入标题';
} else if (formData.title.length > 100) {
newErrors.title = '标题不能超过100个字符';
}
if (!formData.content.trim()) {
newErrors.content = '请输入内容';
} else if (formData.content.length > 5000) {
newErrors.content = '内容不能超过5000个字符';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理图片上传
const handleImageUpload = (e) => {
const files = Array.from(e.target.files);
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
images: [...prev.images, reader.result],
}));
};
reader.readAsDataURL(file);
});
};
// 移除图片
const removeImage = (index) => {
setFormData((prev) => ({
...prev,
images: prev.images.filter((_, i) => i !== index),
}));
};
// 添加标签
const addTag = () => {
if (currentTag.trim() && !formData.tags.includes(currentTag.trim())) {
if (formData.tags.length >= 5) {
toast({
title: '标签数量已达上限',
description: '最多只能添加5个标签',
status: 'warning',
duration: 2000,
});
return;
}
setFormData((prev) => ({
...prev,
tags: [...prev.tags, currentTag.trim()],
}));
setCurrentTag('');
}
};
// 移除标签
const removeTag = (tag) => {
setFormData((prev) => ({
...prev,
tags: prev.tags.filter((t) => t !== tag),
}));
};
// 提交帖子
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
const postData = {
...formData,
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
};
const newPost = await createPost(postData);
toast({
title: '发布成功',
description: '帖子已成功发布到论坛',
status: 'success',
duration: 3000,
});
// 重置表单
setFormData({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
onClose();
// 通知父组件刷新
if (onPostCreated) {
onPostCreated(newPost);
}
} catch (error) {
console.error('发布帖子失败:', error);
toast({
title: '发布失败',
description: error.message || '发布帖子时出错,请稍后重试',
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay bg="blackAlpha.800" />
<ModalContent
bg={forumColors.background.elevated}
borderColor={forumColors.border.gold}
borderWidth="1px"
maxH="90vh"
>
<ModalHeader
color={forumColors.text.primary}
borderBottomWidth="1px"
borderBottomColor={forumColors.border.default}
>
<Text
bgGradient={forumColors.text.goldGradient}
bgClip="text"
fontWeight="bold"
fontSize="xl"
>
发布新帖
</Text>
</ModalHeader>
<ModalCloseButton color={forumColors.text.secondary} />
<ModalBody py="6" overflowY="auto">
<VStack spacing="5" align="stretch">
{/* 标题输入 */}
<FormControl isInvalid={errors.title}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标题
</FormLabel>
<Input
placeholder="给你的帖子起个吸引人的标题..."
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
<FormErrorMessage>{errors.title}</FormErrorMessage>
</FormControl>
{/* 内容输入 */}
<FormControl isInvalid={errors.content}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
内容
</FormLabel>
<Textarea
placeholder="分享你的投资见解、市场观点或交易心得..."
value={formData.content}
onChange={(e) =>
setFormData((prev) => ({ ...prev, content: e.target.value }))
}
minH="200px"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
resize="vertical"
/>
<FormErrorMessage>{errors.content}</FormErrorMessage>
<Text
fontSize="xs"
color={forumColors.text.muted}
mt="2"
textAlign="right"
>
{formData.content.length} / 5000
</Text>
</FormControl>
{/* 图片上传 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
图片最多9张
</FormLabel>
<HStack spacing="3" flexWrap="wrap">
{formData.images.map((img, index) => (
<Box key={index} position="relative" w="100px" h="100px">
<Image
src={img}
alt={`预览 ${index + 1}`}
w="100%"
h="100%"
objectFit="cover"
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
/>
<IconButton
icon={<X size={14} />}
size="xs"
position="absolute"
top="-2"
right="-2"
borderRadius="full"
bg={forumColors.background.main}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.gold}
onClick={() => removeImage(index)}
_hover={{ bg: forumColors.background.hover }}
/>
</Box>
))}
{formData.images.length < 9 && (
<Box
as="label"
w="100px"
h="100px"
display="flex"
alignItems="center"
justifyContent="center"
bg={forumColors.background.secondary}
border="2px dashed"
borderColor={forumColors.border.default}
borderRadius="md"
cursor="pointer"
_hover={{ borderColor: forumColors.border.gold }}
>
<Input
type="file"
accept="image/*"
multiple
display="none"
onChange={handleImageUpload}
/>
<ImagePlus size={24} color={forumColors.text.tertiary} />
</Box>
)}
</HStack>
</Box>
{/* 标签输入 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标签最多5个
</FormLabel>
<HStack mb="3">
<Input
placeholder="输入标签后按回车"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
<IconButton
icon={<Hash size={18} />}
onClick={addTag}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
/>
</HStack>
<HStack spacing="2" flexWrap="wrap">
{formData.tags.map((tag) => (
<Tag
key={tag}
size="md"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
>
<TagLabel>#{tag}</TagLabel>
<TagCloseButton onClick={() => removeTag(tag)} />
</Tag>
))}
</HStack>
</Box>
</VStack>
</ModalBody>
<ModalFooter
borderTopWidth="1px"
borderTopColor={forumColors.border.default}
>
<HStack spacing="3">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleSubmit}
isLoading={isSubmitting}
loadingText="发布中..."
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CreatePostModal;

View File

@@ -0,0 +1,347 @@
/**
* 事件时间轴组件
* 展示帖子相关事件的时间线发展
*/
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Flex,
Badge,
Link,
Icon,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
TrendingUp,
AlertCircle,
FileText,
ExternalLink,
Clock,
Zap,
} from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
// 事件类型配置
const EVENT_TYPES = {
news: {
label: '新闻',
icon: FileText,
color: forumColors.semantic.info,
},
price_change: {
label: '价格变动',
icon: TrendingUp,
color: forumColors.semantic.warning,
},
announcement: {
label: '公告',
icon: AlertCircle,
color: forumColors.semantic.error,
},
analysis: {
label: '分析',
icon: Zap,
color: forumColors.primary[500],
},
};
// 重要性配置
const IMPORTANCE_LEVELS = {
high: {
label: '重要',
color: forumColors.semantic.error,
dotSize: '16px',
},
medium: {
label: '一般',
color: forumColors.semantic.warning,
dotSize: '12px',
},
low: {
label: '提示',
color: forumColors.text.tertiary,
dotSize: '10px',
},
};
const EventTimeline = ({ events = [] }) => {
// 格式化时间
const formatEventTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (!events || events.length === 0) {
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="8"
textAlign="center"
>
<VStack spacing="3">
<Clock size={48} color={forumColors.text.tertiary} />
<Text color={forumColors.text.secondary} fontSize="md">
暂无事件追踪
</Text>
<Text color={forumColors.text.muted} fontSize="sm">
AI 将自动追踪与本帖相关的市场事件
</Text>
</VStack>
</Box>
);
}
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<Clock size={20} color={forumColors.primary[500]} />
<Text
fontSize="lg"
fontWeight="bold"
color={forumColors.text.primary}
>
事件时间轴
</Text>
</HStack>
<Badge
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
>
{events.length} 个事件
</Badge>
</Flex>
{/* 时间轴列表 */}
<VStack align="stretch" spacing="4" position="relative">
{/* 连接线 */}
<Box
position="absolute"
left="7px"
top="20px"
bottom="20px"
w="2px"
bg={forumColors.border.default}
zIndex="0"
/>
{events.map((event, index) => {
const eventType = EVENT_TYPES[event.event_type] || EVENT_TYPES.news;
const importance =
IMPORTANCE_LEVELS[event.importance] || IMPORTANCE_LEVELS.medium;
const EventIcon = eventType.icon;
return (
<MotionBox
key={event.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
position="relative"
zIndex="1"
>
<Flex gap="4">
{/* 时间轴节点 */}
<Box position="relative" flexShrink="0">
{/* 外圈光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="24px"
h="24px"
borderRadius="full"
bg={importance.color}
opacity="0.2"
animation={
index === 0 ? 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite' : 'none'
}
/>
{/* 节点圆点 */}
<Flex
w={importance.dotSize}
h={importance.dotSize}
borderRadius="full"
bg={importance.color}
border="3px solid"
borderColor={forumColors.background.card}
align="center"
justify="center"
>
<Icon
as={EventIcon}
boxSize="8px"
color={forumColors.background.main}
/>
</Flex>
</Box>
{/* 事件内容卡片 */}
<Box
flex="1"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
borderRadius="md"
p="4"
_hover={{
borderColor: forumColors.border.light,
bg: forumColors.background.hover,
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing="2">
{/* 标题和标签 */}
<Flex justify="space-between" align="start" gap="2">
<Text
fontSize="sm"
fontWeight="600"
color={forumColors.text.primary}
flex="1"
>
{event.title}
</Text>
<HStack spacing="2" flexShrink="0">
<Badge
size="sm"
bg="transparent"
color={eventType.color}
border="1px solid"
borderColor={eventType.color}
fontSize="xs"
>
{eventType.label}
</Badge>
{event.importance === 'high' && (
<Badge
size="sm"
bg={forumColors.semantic.error}
color="white"
fontSize="xs"
>
{importance.label}
</Badge>
)}
</HStack>
</Flex>
{/* 描述 */}
{event.description && (
<Text
fontSize="xs"
color={forumColors.text.secondary}
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 相关股票 */}
{event.related_stocks && event.related_stocks.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{event.related_stocks.map((stock) => (
<Badge
key={stock}
size="sm"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
fontSize="xs"
>
{stock}
</Badge>
))}
</HStack>
)}
{/* 底部信息 */}
<Flex justify="space-between" align="center" pt="2">
<Text fontSize="xs" color={forumColors.text.muted}>
{formatEventTime(event.occurred_at)}
</Text>
{event.source_url && (
<Link
href={event.source_url}
isExternal
fontSize="xs"
color={forumColors.primary[500]}
display="flex"
alignItems="center"
gap="1"
_hover={{ textDecoration: 'underline' }}
>
查看来源
<ExternalLink size={12} />
</Link>
)}
</Flex>
</VStack>
</Box>
</Flex>
</MotionBox>
);
})}
</VStack>
{/* CSS 动画 */}
<style>
{`
@keyframes pulse {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.4;
}
}
`}
</style>
</Box>
);
};
export default EventTimeline;

View File

@@ -0,0 +1,203 @@
/**
* 帖子卡片组件 - 类似小红书风格
* 用于论坛主页的帖子展示
*/
import React from 'react';
import {
Box,
Image,
Text,
HStack,
VStack,
Avatar,
Badge,
IconButton,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { Heart, MessageCircle, Eye, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
const PostCard = ({ post }) => {
const navigate = useNavigate();
// 处理卡片点击
const handleCardClick = () => {
navigate(`/value-forum/post/${post.id}`);
};
// 格式化数字1000 -> 1k
const formatNumber = (num) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num;
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
};
return (
<MotionBox
bg={forumColors.background.card}
borderRadius="xl"
overflow="hidden"
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
onClick={handleCardClick}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.3 }}
_hover={{
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
}}
>
{/* 封面图片区域 */}
{post.images && post.images.length > 0 && (
<Box position="relative" overflow="hidden" h="200px">
<Image
src={post.images[0]}
alt={post.title}
w="100%"
h="100%"
objectFit="cover"
transition="transform 0.3s"
_groupHover={{ transform: 'scale(1.1)' }}
/>
{/* 置顶标签 */}
{post.is_pinned && (
<Badge
position="absolute"
top="12px"
right="12px"
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
px="3"
py="1"
borderRadius="full"
fontWeight="bold"
fontSize="xs"
display="flex"
alignItems="center"
gap="1"
>
<TrendingUp size={12} />
置顶
</Badge>
)}
</Box>
)}
{/* 内容区域 */}
<VStack align="stretch" p="4" spacing="3">
{/* 标题 */}
<Text
fontSize="md"
fontWeight="600"
color={forumColors.text.primary}
noOfLines={2}
lineHeight="1.4"
>
{post.title}
</Text>
{/* 内容预览 */}
{post.content && (
<Text
fontSize="sm"
color={forumColors.text.secondary}
noOfLines={2}
lineHeight="1.6"
>
{post.content}
</Text>
)}
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{post.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
fontSize="xs"
fontWeight="500"
>
#{tag}
</Badge>
))}
</HStack>
)}
{/* 底部信息栏 */}
<Flex justify="space-between" align="center" pt="2">
{/* 作者信息 */}
<HStack spacing="2">
<Avatar
size="xs"
name={post.author_name}
src={post.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{post.author_name}
</Text>
</HStack>
{/* 互动数据 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<HStack spacing="1">
<Heart size={14} />
<Text>{formatNumber(post.likes_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<MessageCircle size={14} />
<Text>{formatNumber(post.comments_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<Eye size={14} />
<Text>{formatNumber(post.views_count || 0)}</Text>
</HStack>
</HStack>
</Flex>
{/* 时间 */}
<Text fontSize="xs" color={forumColors.text.muted} textAlign="right">
{formatTime(post.created_at)}
</Text>
</VStack>
</MotionBox>
);
};
export default PostCard;

View File

@@ -0,0 +1,311 @@
/**
* 价值论坛主页面
* 类似小红书/X的帖子广场
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
Button,
HStack,
VStack,
SimpleGrid,
Input,
InputGroup,
InputLeftElement,
Select,
Spinner,
Center,
useDisclosure,
Flex,
Badge,
} from '@chakra-ui/react';
import { Search, PenSquare, TrendingUp, Clock, Heart } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import { getPosts, searchPosts } from '@services/elasticsearchService';
import PostCard from './components/PostCard';
import CreatePostModal from './components/CreatePostModal';
const MotionBox = motion(Box);
const ValueForum = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(true);
const { isOpen, onOpen, onClose } = useDisclosure();
// 获取帖子列表
const fetchPosts = async (currentPage = 1, reset = false) => {
try {
setLoading(true);
let result;
if (searchKeyword.trim()) {
result = await searchPosts(searchKeyword, {
page: currentPage,
size: 20,
});
} else {
result = await getPosts({
page: currentPage,
size: 20,
sort: sortBy,
order: 'desc',
});
}
if (reset) {
setPosts(result.posts);
} else {
setPosts((prev) => [...prev, ...result.posts]);
}
setTotal(result.total);
setHasMore(result.posts.length === 20);
} catch (error) {
console.error('获取帖子列表失败:', error);
} finally {
setLoading(false);
}
};
// 初始化加载
useEffect(() => {
fetchPosts(1, true);
}, [sortBy]);
// 搜索处理
const handleSearch = () => {
setPage(1);
fetchPosts(1, true);
};
// 加载更多
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchPosts(nextPage, false);
};
// 发帖成功回调
const handlePostCreated = () => {
setPage(1);
fetchPosts(1, true);
};
// 排序选项
const sortOptions = [
{ value: 'created_at', label: '最新发布', icon: Clock },
{ value: 'likes_count', label: '最多点赞', icon: Heart },
{ value: 'views_count', label: '最多浏览', icon: TrendingUp },
];
return (
<Box
minH="100vh"
bg={forumColors.background.main}
pt="80px"
pb="20"
>
<Container maxW="container.xl">
{/* 顶部横幅 */}
<MotionBox
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
mb="10"
>
<VStack spacing="4" align="stretch">
{/* 标题区域 */}
<Flex justify="space-between" align="center">
<VStack align="start" spacing="2">
<Heading
as="h1"
fontSize="4xl"
fontWeight="bold"
bgGradient={forumColors.text.goldGradient}
bgClip="text"
>
价值论坛
</Heading>
<Text color={forumColors.text.secondary} fontSize="md">
分享投资见解追踪市场热点共同发现价值
</Text>
</VStack>
{/* 发帖按钮 */}
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
size="lg"
fontWeight="bold"
onClick={onOpen}
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
</Flex>
{/* 搜索和筛选栏 */}
<Flex gap="4" align="center" flexWrap="wrap">
{/* 搜索框 */}
<InputGroup maxW="400px" flex="1">
<InputLeftElement pointerEvents="none">
<Search size={18} color={forumColors.text.tertiary} />
</InputLeftElement>
<Input
placeholder="搜索帖子标题、内容、标签..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
bg={forumColors.background.card}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</InputGroup>
{/* 排序选项 */}
<HStack spacing="2">
{sortOptions.map((option) => {
const Icon = option.icon;
const isActive = sortBy === option.value;
return (
<Button
key={option.value}
leftIcon={<Icon size={16} />}
size="md"
variant={isActive ? 'solid' : 'outline'}
bg={isActive ? forumColors.gradients.goldPrimary : 'transparent'}
color={isActive ? forumColors.background.main : forumColors.text.secondary}
borderColor={forumColors.border.default}
onClick={() => setSortBy(option.value)}
_hover={{
bg: isActive
? forumColors.gradients.goldPrimary
: forumColors.background.hover,
borderColor: forumColors.border.gold,
}}
>
{option.label}
</Button>
);
})}
</HStack>
</Flex>
{/* 统计信息 */}
<HStack spacing="6" color={forumColors.text.tertiary} fontSize="sm">
<Text>
<Text as="span" color={forumColors.primary[500]} fontWeight="bold">{total}</Text>
</Text>
</HStack>
</VStack>
</MotionBox>
{/* 帖子网格 */}
{loading && page === 1 ? (
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
) : posts.length === 0 ? (
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
</Text>
{!searchKeyword && (
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
onClick={onOpen}
_hover={{ opacity: 0.9 }}
>
发布帖子
</Button>
)}
</VStack>
</Center>
) : (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
<AnimatePresence>
{posts.map((post, index) => (
<MotionBox
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<PostCard post={post} />
</MotionBox>
))}
</AnimatePresence>
</SimpleGrid>
{/* 加载更多按钮 */}
{hasMore && (
<Center mt="10">
<Button
onClick={loadMore}
isLoading={loading}
loadingText="加载中..."
bg={forumColors.background.card}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.default}
_hover={{
borderColor: forumColors.border.gold,
bg: forumColors.background.hover,
}}
>
加载更多
</Button>
</Center>
)}
</>
)}
</Container>
{/* 发帖模态框 */}
<CreatePostModal
isOpen={isOpen}
onClose={onClose}
onPostCreated={handlePostCreated}
/>
</Box>
);
};
export default ValueForum;

528
valuefrontier Normal file
View File

@@ -0,0 +1,528 @@
# /etc/nginx/sites-available/valuefrontier
# 完整配置 - Next.js只处理特定页面其他全部导向React应用
# WebSocket 连接升级映射
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP (端口 80) 服务器块: 负责将所有HTTP请求重定向到HTTPS
server {
listen 80;
server_name valuefrontier.cn www.valuefrontier.cn;
# 这一段是为了让Certbot自动续期时能正常工作
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location ~ \.txt$ {
root /var/www/valuefrontier.cn;
add_header Content-Type "text/plain";
}
location / {
# 301永久重定向到安全的HTTPS版本
return 301 https://$host$request_uri;
}
}
# HTTPS (端口 443) 服务器块: 处理所有加密流量和实际的应用逻辑
server {
listen 443 ssl http2;
server_name valuefrontier.cn www.valuefrontier.cn;
# --- SSL 证书配置 ---
ssl_certificate /etc/letsencrypt/live/valuefrontier.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/valuefrontier.cn/privkey.pem;
# --- 为React应用提供静态资源 ---
location /static/ {
alias /var/www/valuefrontier.cn/static/;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
}
location ~ \.txt$ {
root /var/www/valuefrontier.cn;
add_header Content-Type "text/plain";
}
location /manifest.json {
alias /var/www/valuefrontier.cn/manifest.json;
add_header Content-Type "application/json";
}
location /favicon.ico {
alias /var/www/valuefrontier.cn/favicon.ico;
}
# --- Next.js 专用静态资源 (已废弃,保留以防需要回滚) ---
# location /_next/ {
# proxy_pass http://127.0.0.1:3000;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# expires 1y;
# add_header Cache-Control "public, immutable";
# }
# --- 后端API反向代理 ---
location /api/ {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
location /mcp/ {
proxy_pass http://127.0.0.1:8900/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
gzip off;
add_header X-Accel-Buffering no;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# 概念板块API代理
location /concept-api/ {
proxy_pass http://222.128.1.157:16801/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Elasticsearch API代理价值论坛
location /es-api/ {
proxy_pass http://222.128.1.157:19200/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, HEAD' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 禁用缓冲以支持流式响应
proxy_buffering off;
}
# 拜特桌面API代理 (bytedesk)
location /bytedesk/ {
proxy_pass http://43.143.189.195/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# 2. WebSocket代理必需
location /websocket {
proxy_pass http://43.143.189.195/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 86400s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
# iframe 内部资源代理Bytedesk 聊天窗口的 CSS/JS
location /chat/ {
proxy_pass http://43.143.189.195/chat/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ✨ 自动替换响应内容中的 IP 地址为域名(解决 Mixed Content
sub_filter 'http://43.143.189.195' 'https://valuefrontier.cn';
sub_filter_once off; # 替换所有匹配项
sub_filter_types text/css text/javascript application/javascript application/json;
# CORS 头部
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers'
'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Bytedesk 配置接口代理
location /config/ {
proxy_pass http://43.143.189.195/config/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 头部
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers'
'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Bytedesk 上传文件代理
location ^~ /uploads/ {
proxy_pass http://43.143.189.195/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 缓存配置
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Visitor API 代理Bytedesk 初始化接口)
location /visitor/ {
proxy_pass http://43.143.189.195/visitor/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ✨ 禁用压缩(让 sub_filter 能处理 JSON
proxy_set_header Accept-Encoding "";
# ✨ 替换 JSON 响应中的 IP 地址为域名
sub_filter 'http://43.143.189.195' 'https://valuefrontier.cn';
sub_filter_once off;
sub_filter_types application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location = /stomp {
proxy_pass http://43.143.189.195/api/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
location /stomp/ {
proxy_pass http://43.143.189.195/api/websocket/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
# 6. 头像和静态资源解决404问题
location ^~ /avatars/ {
proxy_pass http://43.143.189.195/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 缓存静态资源
proxy_cache_valid 200 1d;
proxy_cache_bypass $http_cache_control;
}
# 7. Bytedesk所有其他资源
location /assets/ {
proxy_pass http://43.143.189.195/assets/;
proxy_set_header Host $host;
expires 1d;
add_header Cache-Control "public, immutable";
}
# 新闻搜索API代理
location /news-api/ {
proxy_pass http://222.128.1.157:21891/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 90s;
}
# 研报搜索API代理
location /report-api/ {
proxy_pass http://222.128.1.157:8811/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# --- 新的静态官网静态资源(优先级最高) ---
# 使用 ^~ 前缀确保优先匹配,不被后面的规则覆盖
location ^~ /css/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /js/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /img/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /videos/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /fonts/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 官网HTML页面
location ~ ^/(conversational-ai|customizable-workflows|integration|docs|sign-in|sign-up|reset-password)\.html$ {
root /var/www/valuefrontier;
try_files $uri =404;
}
# 官网首页 - 静态HTML
location = / {
root /var/www/valuefrontier;
try_files /index.html =404;
}
# --- React 应用 (默认 catch-all) ---
# 所有其他路径都导向React应用
# 包括: /home, /community, /concepts, /limit-analyse, /stocks 等
location / {
root /var/www/valuefrontier.cn;
index index.html index.htm;
# 所有SPA路由都返回index.html让React处理
try_files $uri $uri/ /index.html;
# 为静态资源添加缓存头
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
}
}
}
# ==========================================================
# api.valuefrontier.cn - 服务器配置块 (START)
# ==========================================================
server {
if ($host = api.valuefrontier.cn) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name api.valuefrontier.cn; # <--- 明确指定域名
# 这一段是为了让Certbot自动续期时能正常工作
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# 将所有到 api 域名的 HTTP 请求重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.valuefrontier.cn; # <--- 明确指定域名
# --- SSL 证书配置 ---
# Certbot 会自动填充这里,但我们可以先写上占位符
ssl_certificate /etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem; # managed by Certbot
# (推荐) 包含通用的SSL安全配置
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# --- API 反向代理 ---
location / {
# 假设你的API后端运行在本地的8080端口
# 请修改为你API服务的真实地址和端口
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ==========================================================
# api.valuefrontier.cn - 服务器配置块 (END)
# ==========================================================