Compare commits

...

82 Commits

Author SHA1 Message Date
zdl
53fbda44e6 feat: 忽略 docs 目录(开发文档不提交到 Git) 2025-11-20 15:07:45 +08:00
zdl
540b938525 feat: 删除md文件 2025-11-20 15:07:45 +08:00
zdl
8fe11efcd7 refactor: 清理 Bytedesk 集成文件,移动文档到 docs 目录 2025-11-20 15:07:45 +08:00
zdl
e753437b86 feat: 调整客服配置 2025-11-20 15:07:45 +08:00
zdl
a6f69418f6 feat: 添加SEOpng 2025-11-20 15:07:45 +08:00
zdl
dfdd2f4134 feat: posthog 配置调整 2025-11-20 15:07:45 +08:00
zdl
4c79871ab4 pref: 去除无效配置 2025-11-20 15:07:45 +08:00
f8eb268341 update pay function 2025-11-20 15:05:58 +08:00
665f5e8416 update pay function 2025-11-20 14:51:43 +08:00
be2da54d82 update pay function 2025-11-20 14:42:26 +08:00
8bf4a0b6c6 update pay function 2025-11-20 14:36:13 +08:00
412b2c03ed update pay function 2025-11-20 14:33:09 +08:00
899500007d update pay function 2025-11-20 14:30:32 +08:00
d3879b3840 update pay function 2025-11-20 14:24:24 +08:00
80fe74c041 update pay function 2025-11-20 14:13:33 +08:00
78f7dca1f6 update pay function 2025-11-20 13:57:11 +08:00
03aee75235 update pay function 2025-11-20 13:43:04 +08:00
8eff6b1a95 update pay function 2025-11-20 13:25:50 +08:00
80676dd622 update pay function 2025-11-20 12:55:28 +08:00
082e644534 update pay function 2025-11-20 08:33:26 +08:00
b0b227a5ef update pay function 2025-11-20 08:18:02 +08:00
691c4f6eb1 update pay function 2025-11-20 08:09:34 +08:00
d5a55c4e02 update pay function 2025-11-20 08:02:34 +08:00
27cdf0aecd update pay function 2025-11-20 07:57:15 +08:00
4a1157c0b6 update pay function 2025-11-20 07:46:50 +08:00
f515dc94f4 update pay function 2025-11-19 23:41:45 +08:00
683e261756 update pay function 2025-11-19 23:06:14 +08:00
8bdfd0389c update pay function 2025-11-19 22:56:28 +08:00
eae495ac34 Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-19 21:46:07 +08:00
958cedefb8 update pay function 2025-11-19 21:45:55 +08:00
zdl
1fc9f4790f pref: 清理建议
6.1 立即可删除(安全)

  以下文件可以立即删除,不会影响任何功能:

  # 未使用的组件
  src/views/Community/components/EventList.js
  src/views/Community/components/EventListSection.js
  src/views/Community/components/EventTimelineHeader.js
  src/views/Community/components/MarketReviewCard.js
  src/views/Community/components/UnifiedSearchBox.js
  src/views/Community/components/ImportanceLegend.js
  src/views/Community/components/IndustryCascader.js
  src/views/Community/components/EventDetailModal.js

  # 未使用的CSS
  src/views/Community/components/EventList.css

  # 备份文件
  src/views/Community/components/EventList.js.bak

  # 测试文档
  src/views/Community/components/DynamicNewsDetail/1.md

  预计减少代码量:~2000行代码
2025-11-19 21:27:24 +08:00
b48ff99658 update pay function 2025-11-19 20:44:35 +08:00
ae558996b6 update pay function 2025-11-19 20:23:56 +08:00
71742c0116 update pay function 2025-11-19 20:15:27 +08:00
2ead50c37c update pay function 2025-11-19 19:55:07 +08:00
9e8519bb94 Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-19 19:41:59 +08:00
a4d16e7686 update pay function 2025-11-19 19:41:26 +08:00
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
122 changed files with 14867 additions and 35739 deletions

View File

@@ -18,10 +18,3 @@ REACT_APP_ENABLE_MOCK=false
# 开发环境标识
REACT_APP_ENV=development
# PostHog 配置(开发环境)
# 留空 = 仅控制台 debug
# 填入 Key = 控制台 + PostHog Cloud 双模式
REACT_APP_POSTHOG_KEY=
REACT_APP_POSTHOG_HOST=https://app.posthog.com
REACT_APP_ENABLE_SESSION_RECORDING=false

View File

@@ -35,14 +35,3 @@ REACT_APP_ENABLE_MOCK=true
# Mock 环境标识
REACT_APP_ENV=mock
# PostHog 配置Mock 环境)
# 留空 = 仅控制台 debug
# 填入 Key = 控制台 + PostHog Cloud 双模式
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
REACT_APP_POSTHOG_HOST=https://app.posthog.com
REACT_APP_ENABLE_SESSION_RECORDING=false
# PostHog Debug 模式Mock 环境永久启用)
# 在浏览器 Console 中打印详细的事件追踪日志
REACT_APP_POSTHOG_DEBUG=true

View File

@@ -1,13 +1,6 @@
# ========================================
# 生产环境配置
# ========================================
# 使用方式: npm run build
#
# 工作原理:
# 1. 此文件专门用于生产环境构建
# 2. 构建时会将环境变量嵌入到打包文件中
# 3. 确保 PostHog 等服务使用正确的生产配置
# ========================================
# 环境标识
REACT_APP_ENV=production
@@ -17,13 +10,8 @@ NODE_ENV=production
REACT_APP_ENABLE_MOCK=false
# 🔧 调试模式(生产环境临时调试用)
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
# 使用方法:
# 1. 设置为 true 并重新构建
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
# 3. 调试完成后设置为 false 并重新构建
REACT_APP_ENABLE_DEBUG=true
# 开启后会在全局暴露 window.__DEBUG__
REACT_APP_ENABLE_DEBUG=false
# 后端 API 地址(生产环境)
REACT_APP_API_URL=http://49.232.185.254:5001
@@ -49,20 +37,3 @@ TSC_COMPILE_ON_ERROR=true
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096
# ========================================
# Bytedesk 客服系统配置
# ========================================
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
# Nginx 配置location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
# 组织 UUID从管理后台 -> 设置 -> 组织信息 -> 组织UUID
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUID从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
REACT_APP_BYTEDESK_SID=df_wg_uid
# 客服类型2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2

4
.gitignore vendored
View File

@@ -48,6 +48,8 @@ Thumbs.db
*.md
!README.md
!CLAUDE.md
!docs/**/*.md
# 忽略 docs 目录(开发文档不提交到 Git
docs/
src/assets/img/original-backup/

View File

@@ -44,7 +44,7 @@
**前端**
- **核心框架**: React 18.3.1
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
- **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单)
- **状态管理**: Redux Toolkit 2.9.2
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化

581
app.py
View File

@@ -570,6 +570,28 @@ class User(UserMixin, db.Model):
'is_authenticated': True
}
# 获取用户订阅信息(从 user_subscriptions 表)
subscription = UserSubscription.query.filter_by(user_id=self.id).first()
if subscription:
data.update({
'subscription_type': subscription.subscription_type,
'subscription_status': subscription.subscription_status,
'billing_cycle': subscription.billing_cycle,
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
'auto_renewal': subscription.auto_renewal
})
else:
# 无订阅时使用默认值
data.update({
'subscription_type': 'free',
'subscription_status': 'inactive',
'billing_cycle': None,
'start_date': None,
'end_date': None,
'auto_renewal': False
})
# 敏感信息只在需要时包含
if include_sensitive:
data.update({
@@ -1127,36 +1149,61 @@ def get_user_subscription_safe(user_id):
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
"""激活用户订阅
"""
激活用户订阅(新版:续费时从当前订阅结束时间开始延长)
Args:
user_id: 用户ID
plan_type: 套餐类型
billing_cycle: 计费周期
extend_from_now: 是否从当前时间开始延长(用于升级场景
plan_type: 套餐类型 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
extend_from_now: 废弃参数,保留以兼容(现在自动判断
Returns:
UserSubscription 对象 或 None
"""
try:
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
# 新用户,创建订阅记录
subscription = UserSubscription(user_id=user_id)
db.session.add(subscription)
# 更新订阅类型和状态
subscription.subscription_type = plan_type
subscription.subscription_status = 'active'
subscription.billing_cycle = billing_cycle
if not extend_from_now or not subscription.start_date:
subscription.start_date = beijing_now()
# 计算订阅周期天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90, # 3个月
'semiannual': 180, # 6个月
'yearly': 365
}
days = cycle_days_map.get(billing_cycle, 30)
if billing_cycle == 'monthly':
subscription.end_date = beijing_now() + timedelta(days=30)
else: # yearly
subscription.end_date = beijing_now() + timedelta(days=365)
now = beijing_now()
# 判断是新购还是续费
if subscription.end_date and subscription.end_date > now:
# 续费:从当前订阅结束时间开始延长
start_date = subscription.end_date
end_date = start_date + timedelta(days=days)
else:
# 新购或过期后重新购买:从当前时间开始
start_date = now
end_date = now + timedelta(days=days)
subscription.start_date = start_date
subscription.end_date = end_date
subscription.updated_at = now
subscription.updated_at = beijing_now()
db.session.commit()
return subscription
except Exception as e:
print(f"激活订阅失败: {e}")
db.session.rollback()
return None
@@ -1233,33 +1280,29 @@ def calculate_discount(promo_code, amount):
return 0
def calculate_remaining_value(subscription, current_plan):
"""计算当前订阅的剩余价值"""
try:
if not subscription or not subscription.end_date:
return 0
def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None):
"""
简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值
now = beijing_now()
if subscription.end_date <= now:
return 0
days_left = (subscription.end_date - now).days
if subscription.billing_cycle == 'monthly':
daily_value = float(current_plan.monthly_price) / 30
else: # yearly
daily_value = float(current_plan.yearly_price) / 365
return daily_value * days_left
except:
return 0
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""计算升级所需价格
Args:
user_id: 用户ID
to_plan_name: 目标套餐名称 (pro/max)
to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
Returns:
dict: 包含价格计算结果的字典
dict: {
'is_renewal': False/True, # 是否为续费
'subscription_type': 'new'/'renew', # 订阅类型
'current_plan': 'pro', # 当前套餐(如果有)
'current_cycle': 'yearly', # 当前周期(如果有)
'new_plan_price': 2699.00, # 新套餐价格
'original_amount': 2699.00, # 原价
'discount_amount': 0, # 优惠金额
'final_amount': 2699.00, # 实付金额
'promo_code': None, # 使用的优惠码
'promo_error': None # 优惠码错误信息
}
"""
try:
# 1. 获取当前订阅
@@ -1270,83 +1313,176 @@ def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
if not to_plan:
return {'error': '目标套餐不存在'}
# 3. 计算目标套餐价格
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
# 3. 根据计费周期获取价格
# 优先从 pricing_options 获取价格
price = None
if to_plan.pricing_options:
try:
pricing_opts = json.loads(to_plan.pricing_options)
# 查找匹配的周期
for opt in pricing_opts:
cycle_key = opt.get('cycle_key', '')
months = opt.get('months', 0)
# 4. 如果是新订阅(非升级)
if not current_sub or current_sub.subscription_type == 'free':
result = {
'is_upgrade': False,
'new_plan_price': new_price,
'remaining_value': 0,
'upgrade_amount': new_price,
'original_amount': new_price,
'discount_amount': 0,
'final_amount': new_price,
'promo_code': None
}
# 匹配逻辑
if (cycle_key == to_cycle or
(to_cycle == 'monthly' and months == 1) or
(to_cycle == 'quarterly' and months == 3) or
(to_cycle == 'semiannual' and months == 6) or
(to_cycle == 'yearly' and months == 12)):
price = float(opt.get('price', 0))
break
except:
pass
# 应用优惠码
if promo_code:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
if promo:
discount = calculate_discount(promo, new_price)
result['discount_amount'] = discount
result['final_amount'] = new_price - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
# 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price
if price is None:
if to_cycle == 'yearly':
price = float(to_plan.yearly_price) if to_plan.yearly_price else 0
else: # 默认月付
price = float(to_plan.monthly_price) if to_plan.monthly_price else 0
return result
if price <= 0:
return {'error': f'{to_cycle} 周期价格未配置'}
# 5. 升级场景:计算剩余价值
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
if not current_plan:
return {'error': '当前套餐信息不存在'}
# 4. 判断订阅类型和计算价格
is_renewal = False
is_upgrade = False
is_downgrade = False
subscription_type = 'new'
current_plan = None
current_cycle = None
remaining_value = 0
final_price = price
remaining_value = calculate_remaining_value(current_sub, current_plan)
if current_sub and current_sub.subscription_type in ['pro', 'max']:
current_plan = current_sub.subscription_type
current_cycle = current_sub.billing_cycle
# 6. 计算升级差价
upgrade_amount = max(0, new_price - remaining_value)
if current_plan == to_plan_name:
# 同级续费:延长时长,全价购买
is_renewal = True
subscription_type = 'renew'
elif current_plan == 'pro' and to_plan_name == 'max':
# 升级Pro → Max需要计算差价
is_upgrade = True
subscription_type = 'upgrade'
# 7. 判断升级类型
upgrade_type = 'new'
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
upgrade_type = 'both'
elif current_sub.subscription_type != to_plan_name:
upgrade_type = 'plan_upgrade'
elif current_sub.billing_cycle != to_cycle:
upgrade_type = 'cycle_change'
# 计算当前订阅的剩余价值
if current_sub.end_date and current_sub.end_date > datetime.utcnow():
# 获取当前套餐的原始价格
current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first()
if current_plan_obj:
current_price = None
# 优先从 pricing_options 获取价格
if current_plan_obj.pricing_options:
try:
pricing_opts = json.loads(current_plan_obj.pricing_options)
# 如果 current_cycle 为空或无效,根据剩余天数推断计费周期
if not current_cycle or current_cycle.strip() == '':
remaining_days_total = (current_sub.end_date - current_sub.start_date).days if current_sub.start_date else 365
# 根据总天数推断计费周期
if remaining_days_total <= 35:
inferred_cycle = 'monthly'
elif remaining_days_total <= 100:
inferred_cycle = 'quarterly'
elif remaining_days_total <= 200:
inferred_cycle = 'semiannual'
else:
inferred_cycle = 'yearly'
else:
inferred_cycle = current_cycle
for opt in pricing_opts:
if opt.get('cycle_key') == inferred_cycle:
current_price = float(opt.get('price', 0))
current_cycle = inferred_cycle # 更新周期信息
break
except:
pass
# 如果 pricing_options 中没找到,使用 yearly_price 作为默认
if current_price is None or current_price <= 0:
current_price = float(current_plan_obj.yearly_price) if current_plan_obj.yearly_price else 0
current_cycle = 'yearly'
if current_price and current_price > 0:
# 计算剩余天数
remaining_days = (current_sub.end_date - datetime.utcnow()).days
# 计算总天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
total_days = cycle_days_map.get(current_cycle, 365)
# 计算剩余价值
if total_days > 0 and remaining_days > 0:
remaining_value = current_price * (remaining_days / total_days)
# 实付金额 = 新套餐价格 - 剩余价值
final_price = max(0, price - remaining_value)
# 如果剩余价值 >= 新套餐价格,标记为免费升级
if remaining_value >= price:
final_price = 0
elif current_plan == 'max' and to_plan_name == 'pro':
# 降级Max → Pro到期后切换全价购买
is_downgrade = True
subscription_type = 'downgrade'
else:
# 其他情况视为新购
subscription_type = 'new'
# 5. 构建结果
result = {
'is_upgrade': True,
'upgrade_type': upgrade_type,
'current_plan': current_sub.subscription_type,
'current_cycle': current_sub.billing_cycle,
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
'new_plan_price': new_price,
'remaining_value': remaining_value,
'upgrade_amount': upgrade_amount,
'original_amount': upgrade_amount,
'is_renewal': is_renewal,
'is_upgrade': is_upgrade,
'is_downgrade': is_downgrade,
'subscription_type': subscription_type,
'current_plan': current_plan,
'current_cycle': current_cycle,
'new_plan_price': price,
'original_price': price, # 新套餐原价
'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效)
'original_amount': price,
'discount_amount': 0,
'final_amount': upgrade_amount,
'promo_code': None
'final_amount': final_price,
'promo_code': None,
'promo_error': None
}
# 8. 应用优惠码
if promo_code and upgrade_amount > 0:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
# 6. 应用优惠码(基于差价后的金额)
if promo_code and promo_code.strip():
# 优惠码作用于差价后的金额
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id)
if promo:
discount = calculate_discount(promo, upgrade_amount)
result['discount_amount'] = discount
result['final_amount'] = upgrade_amount - discount
discount = calculate_discount(promo, final_price)
result['discount_amount'] = float(discount)
result['final_amount'] = final_price - float(discount)
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
except Exception as e:
return {'error': str(e)}
return {'error': f'价格计算失败: {str(e)}'}
# 保留旧函数以兼容(标记为废弃)
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""
【已废弃】旧版升级价格计算函数,保留以兼容旧代码
新代码请使用 calculate_subscription_price_simple
"""
# 直接调用新函数
return calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code)
def initialize_subscription_plans_safe():
@@ -1594,7 +1730,33 @@ def validate_promo_code_api():
@app.route('/api/subscription/calculate-price', methods=['POST'])
def calculate_subscription_price():
"""计算订阅价格(支持升级和优惠码)"""
"""
计算订阅价格(新版:续费和新购价格一致)
Request Body:
{
"to_plan": "pro",
"to_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"is_renewal": true, // 是否为续费
"subscription_type": "renew", // new 或 renew
"current_plan": "pro", // 当前套餐(如果有)
"current_cycle": "monthly", // 当前周期(如果有)
"new_plan_price": 2699.00,
"original_amount": 2699.00,
"discount_amount": 0,
"final_amount": 2699.00,
"promo_code": null,
"promo_error": null
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1602,13 +1764,13 @@ def calculate_subscription_price():
data = request.get_json()
to_plan = data.get('to_plan')
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:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 计算价格
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
# 使用新的简化价格计算函数
result = calculate_subscription_price_simple(session['user_id'], to_plan, to_cycle, promo_code)
if 'error' in result:
return jsonify({
@@ -1628,9 +1790,101 @@ def calculate_subscription_price():
}), 500
@app.route('/api/subscription/free-upgrade', methods=['POST'])
@login_required
def free_upgrade_subscription():
"""
免费升级订阅(当剩余价值 >= 新套餐价格时)
Request Body:
{
"plan_name": "max",
"billing_cycle": "yearly"
}
"""
try:
data = request.get_json()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
user_id = current_user.id
# 计算价格,验证是否可以免费升级
price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, None)
if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400
# 检查是否为升级且实付金额为0
if not price_result.get('is_upgrade') or price_result.get('final_amount', 1) > 0:
return jsonify({'success': False, 'error': '当前情况不符合免费升级条件'}), 400
# 获取当前订阅
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
return jsonify({'success': False, 'error': '未找到订阅记录'}), 404
# 计算新的到期时间(按剩余价值折算)
remaining_value = price_result.get('remaining_value', 0)
new_plan_price = price_result.get('new_plan_price', 0)
if new_plan_price > 0:
# 计算可以兑换的新套餐天数
value_ratio = remaining_value / new_plan_price
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
new_cycle_days = cycle_days_map.get(billing_cycle, 365)
# 新的到期天数 = 周期天数 × 价值比例
new_days = int(new_cycle_days * value_ratio)
# 更新订阅信息
subscription.subscription_type = plan_name
subscription.billing_cycle = billing_cycle
subscription.start_date = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=new_days)
subscription.subscription_status = 'active'
subscription.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': f'升级成功!您的{plan_name.upper()}版本将持续{new_days}',
'data': {
'subscription_type': plan_name,
'end_date': subscription.end_date.isoformat(),
'days': new_days
}
})
else:
return jsonify({'success': False, 'error': '价格计算异常'}), 500
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'升级失败: {str(e)}'}), 500
@app.route('/api/payment/create-order', methods=['POST'])
def create_payment_order():
"""创建支付订单(支持升级和优惠码)"""
"""
创建支付订单(新版:简化逻辑,不再记录升级)
Request Body:
{
"plan_name": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1638,21 +1892,28 @@ def create_payment_order():
data = request.get_json()
plan_name = data.get('plan_name')
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:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 计算价格(包括升级和优惠码)
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
# 使用新的简化价格计算
price_result = calculate_subscription_price_simple(session['user_id'], plan_name, billing_cycle, promo_code)
if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400
amount = price_result['final_amount']
original_amount = price_result['original_amount']
discount_amount = price_result['discount_amount']
is_upgrade = price_result.get('is_upgrade', False)
subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
# 检查是否为免费升级金额为0
if amount <= 0 and price_result.get('is_upgrade'):
return jsonify({
'success': False,
'error': '当前剩余价值可直接免费升级,请使用免费升级功能',
'should_free_upgrade': True,
'price_info': price_result
}), 400
# 创建订单
try:
@@ -1663,48 +1924,23 @@ def create_payment_order():
amount=amount
)
# 添加扩展字段(使用动态属性
if hasattr(order, 'original_amount') or True: # 兼容性检查
order.original_amount = original_amount
order.discount_amount = discount_amount
order.is_upgrade = is_upgrade
# 添加订阅类型标记(用于前端展示
order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅"
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
# 如果没有该字段,这行会报错,可以注释掉
try:
order.promo_code_id = promo_obj.id
# 如果是升级,记录原套餐信息
if is_upgrade:
order.upgrade_from_plan = price_result.get('current_plan')
except:
pass # 如果表中没有该字段,跳过
db.session.add(order)
db.session.commit()
# 如果是升级订单,创建升级记录
if is_upgrade and price_result.get('upgrade_type'):
try:
upgrade_record = SubscriptionUpgrade(
user_id=session['user_id'],
order_id=order.id,
from_plan=price_result['current_plan'],
from_cycle=price_result['current_cycle'],
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
to_plan=plan_name,
to_cycle=billing_cycle,
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
remaining_value=price_result['remaining_value'],
upgrade_amount=price_result['upgrade_amount'],
actual_amount=amount,
upgrade_type=price_result['upgrade_type']
)
db.session.add(upgrade_record)
db.session.commit()
except Exception as e:
print(f"创建升级记录失败: {e}")
# 不影响主流程
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
@@ -3424,8 +3660,20 @@ def login_with_wechat():
# 更新最后登录时间
user.update_last_seen()
# 清除session
del wechat_qr_sessions[session_id]
# ✅ 修复不立即删除session而是标记为已完成避免轮询报错
# 原因:前端可能还在轮询检查状态,立即删除会导致 "无效的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 = {
@@ -3442,7 +3690,8 @@ def login_with_wechat():
'wechat_union_id': user.wechat_union_id,
'created_at': user.created_at.isoformat() if user.created_at else None,
'last_seen': user.last_seen.isoformat() if user.last_seen else None
}
},
'isNewUser': session['status'] == 'register_ready' # 标记是否为新用户
}
# 如果需要token认证可以在这里生成
@@ -4128,6 +4377,52 @@ def get_my_event_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'])
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

968
category_tree_openapi.json Normal file
View File

@@ -0,0 +1,968 @@
{
"openapi": "3.0.0",
"info": {
"title": "化工商品分类树API",
"description": "提供SMM和Mysteel化工商品数据的分类树状结构API接口。\n\n## 功能特点\n- 树状数据在服务启动时加载到内存,响应速度快\n- 支持获取完整分类树或按路径查询特定节点\n- SMM数据: 127,509个指标, 最大深度8层\n- Mysteel数据: 272,450个指标, 最大深度10层\n\n## 数据结构\n每个树节点包含:\n- name: 节点名称\n- path: 完整路径(用|分隔)\n- level: 层级深度\n- children: 子节点数组\n- metrics: 该节点下的指标列表(仅叶子节点)\n",
"version": "1.0.0",
"contact": {
"name": "API Support"
}
},
"servers": [
{
"url": "http://localhost:18827",
"description": "本地开发服务器"
},
{
"url": "http://222.128.1.157:18827",
"description": "生产服务器"
}
],
"tags": [
{
"name": "搜索",
"description": "指标搜索相关接口"
},
{
"name": "分类树",
"description": "分类树状结构相关接口"
},
{
"name": "数据查询",
"description": "指标时间序列数据查询接口"
}
],
"paths": {
"/api/search": {
"get": {
"tags": [
"搜索"
],
"summary": "搜索化工商品指标",
"description": "基于Elasticsearch的多关键词模糊搜索,支持智能分词和相关度排序。\n\n## 功能特点\n- **多关键词搜索**: 支持空格分隔多个关键词,自动AND逻辑组合\n- **模糊匹配**: 自动容错1-2个字符的拼写错误\n- **多字段匹配**: 同时搜索指标名称、分类路径等多个字段\n- **相关度排序**: 自动按匹配度评分排序,最相关的结果排在前面\n- **灵活过滤**: 支持按数据源(SMM/Mysteel)和频率(日/周/月)过滤\n\n## 搜索字段权重\n- 指标名称(metric_name): 权重最高 (3x)\n- 分类层级(category_levels): 权重中等 (2x)\n- 分类路径(category_path): 权重中等 (2x)\n\n## 使用场景\n- 用户输入关键词快速查找指标\n- 自动补全和搜索建议\n- 按类别和数据源筛选指标\n\n## 搜索示例\n- 搜索\"电解液 产量\": 查找包含\"电解液\"和\"产量\"的指标\n- 搜索\"硫酸钴\": 查找所有硫酸钴相关指标\n- 搜索\"焦炭 价格 日\": 查找焦炭日度价格数据\n",
"operationId": "searchMetrics",
"parameters": [
{
"name": "keywords",
"in": "query",
"description": "搜索关键词,支持空格分隔多个词。\n\n示例:\n- \"电解液 产量\" - 查找同时包含这两个词的指标\n- \"硫酸钴\" - 查找硫酸钴相关指标\n- \"焦炭 价格\" - 查找焦炭价格数据\n",
"required": true,
"schema": {
"type": "string"
},
"example": "电解液 产量"
},
{
"name": "source",
"in": "query",
"description": "数据源过滤(可选)。\n\n- SMM: 上海有色网数据\n- Mysteel: 我的钢铁网数据\n- 不指定: 搜索所有数据源\n",
"required": false,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
},
{
"name": "frequency",
"in": "query",
"description": "数据频率过滤(可选)。\n\n- 日: 日度数据\n- 周: 周度数据\n- 月: 月度数据\n- 不指定: 搜索所有频率\n",
"required": false,
"schema": {
"type": "string",
"enum": [
"日",
"周",
"月"
]
},
"example": "日"
},
{
"name": "size",
"in": "query",
"description": "返回结果数量限制",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100
},
"example": 10
}
],
"responses": {
"200": {
"description": "成功返回搜索结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponse"
},
"examples": {
"基础搜索示例": {
"value": {
"total": 50,
"query": "电解液 产量",
"results": [
{
"source": "SMM",
"metric_id": "12345",
"metric_name": "SMM中国电解液月度产量",
"unit": "吨",
"frequency": "月",
"category_path": "新能源|电解液|产量|SMM中国电解液月度产量",
"description": "",
"score": 15.8
},
{
"source": "SMM",
"metric_id": "12346",
"metric_name": "SMM中国电解液周度产量",
"unit": "吨",
"frequency": "周",
"category_path": "新能源|电解液|产量|SMM中国电解液周度产量",
"description": "",
"score": 14.2
}
]
}
},
"过滤搜索示例": {
"value": {
"total": 15,
"query": "硫酸钴",
"results": [
{
"source": "SMM",
"metric_id": "23456",
"metric_name": "SMM中国硫酸钴月度产量",
"unit": "吨",
"frequency": "月",
"category_path": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量",
"description": "",
"score": 18.5
}
]
}
}
}
}
}
},
"400": {
"description": "请求参数错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "keywords参数不能为空"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "搜索服务暂时不可用"
}
}
}
}
}
}
},
"/api/category-tree": {
"get": {
"tags": [
"分类树"
],
"summary": "获取分类树(支持深度控制)",
"description": "获取指定数据源的分类树状结构,支持深度控制。\n\n## 使用场景\n- 前端树形组件初始化(默认只加载第一层)\n- 懒加载:用户展开时再加载下一层\n- 级联选择器数据源\n\n## 默认行为\n- **默认只返回第一层** (max_depth=1),大幅减少数据传输量\n- SMM第一层约43个节点,Mysteel第一层约2个节点\n- 完整树数据量: SMM约53MB, Mysteel约152MB\n\n## 推荐用法\n1. 首次加载:不传max_depth(默认1层)\n2. 用户点击节点:调用 /api/category-tree/node 获取子节点\n",
"operationId": "getCategoryTree",
"parameters": [
{
"name": "source",
"in": "query",
"description": "数据源类型",
"required": true,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
},
{
"name": "max_depth",
"in": "query",
"description": "返回的最大层级深度\n- 1: 只返回第一层(默认,推荐)\n- 2: 返回前两层\n- 999: 返回完整树(不推荐,数据量大)\n",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 1
},
"example": 1
}
],
"responses": {
"200": {
"description": "成功返回分类树",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CategoryTreeResponse"
},
"examples": {
"SMM示例": {
"value": {
"source": "SMM",
"total_metrics": 127509,
"tree": [
{
"name": "农业食品农资",
"path": "农业食品农资",
"level": 1,
"children": [
{
"name": "饲料",
"path": "农业食品农资|饲料",
"level": 2,
"children": [],
"metrics": []
}
]
}
]
}
},
"Mysteel示例": {
"value": {
"source": "Mysteel",
"total_metrics": 272450,
"tree": [
{
"name": "钢铁产业",
"path": "钢铁产业",
"level": 1,
"children": []
}
]
}
}
}
}
}
},
"404": {
"description": "未找到指定数据源",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/category-tree/node": {
"get": {
"tags": [
"分类树"
],
"summary": "获取特定节点及其子树",
"description": "根据路径获取树中的特定节点及其所有子节点。\n\n## 使用场景\n- 懒加载:用户点击节点时动态加载子节点\n- 子树查询:获取某个分类下的所有数据\n- 面包屑导航:根据路径定位节点\n\n## 路径格式\n使用竖线(|)分隔层级,例如:\n- 一级: \"钴\"\n- 二级: \"钴|钴化合物\"\n- 三级: \"钴|钴化合物|硫酸钴\"\n",
"operationId": "getCategoryTreeNode",
"parameters": [
{
"name": "path",
"in": "query",
"description": "节点完整路径,用竖线(|)分隔",
"required": true,
"schema": {
"type": "string"
},
"example": "钴|钴化合物|硫酸钴"
},
{
"name": "source",
"in": "query",
"description": "数据源类型",
"required": true,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
}
],
"responses": {
"200": {
"description": "成功返回节点数据",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TreeNode"
},
"example": {
"name": "硫酸钴",
"path": "钴|钴化合物|硫酸钴",
"level": 3,
"children": [
{
"name": "产量",
"path": "钴|钴化合物|硫酸钴|产量",
"level": 4,
"children": [],
"metrics": [
{
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"description": ""
}
]
}
]
}
}
}
},
"404": {
"description": "未找到指定路径的节点或数据源",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"节点不存在": {
"value": {
"detail": "未找到路径 '钴|不存在的节点' 对应的节点"
}
},
"数据源不存在": {
"value": {
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
}
}
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/metric-data": {
"get": {
"tags": [
"数据查询"
],
"summary": "获取指标时间序列数据",
"description": "根据指标ID查询历史时间序列数据,自动识别数据源(SMM或Mysteel)。\n\n## 功能特点\n- **自动识别数据源**: 无需指定source参数,系统自动查找\n- **灵活的日期范围**: 支持可选的开始/结束日期过滤\n- **数据限制**: 支持limit参数控制返回数据量\n\n## 日期格式支持\n- YYYY-MM-DD (推荐): \"2024-01-01\"\n- YYYYMMDD: \"20240101\"\n- YYYYMMDDHHmmss: \"20240101000000\"(只取日期部分)\n\n## 使用场景\n- 用户点击树节点查看指标数据\n- 图表展示时间序列数据\n- 数据导出和分析\n",
"operationId": "getMetricData",
"parameters": [
{
"name": "metric_id",
"in": "query",
"description": "指标唯一ID",
"required": true,
"schema": {
"type": "string"
},
"example": "12345"
},
{
"name": "start_date",
"in": "query",
"description": "开始日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
"required": false,
"schema": {
"type": "string"
},
"example": "2024-01-01"
},
{
"name": "end_date",
"in": "query",
"description": "结束日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
"required": false,
"schema": {
"type": "string"
},
"example": "2024-12-31"
},
{
"name": "limit",
"in": "query",
"description": "返回数据条数限制(1-10000)",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 10000,
"default": 100
},
"example": 100
}
],
"responses": {
"200": {
"description": "成功返回指标数据",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetricDataResponse"
},
"examples": {
"SMM数据示例": {
"value": {
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"data": [
{
"date": "2024-12-01",
"value": 12500.5
},
{
"date": "2024-11-01",
"value": 12300.0
},
{
"date": "2024-10-01",
"value": 12100.8
}
],
"total_count": 120
}
},
"Mysteel数据示例": {
"value": {
"metric_id": "A0101010",
"metric_name": "唐山焦炭价格",
"source": "MYSTEEL",
"frequency": "日",
"unit": "元/吨",
"data": [
{
"date": "2024-12-20",
"value": 2350.0
},
{
"date": "2024-12-19",
"value": 2340.0
}
],
"total_count": 365
}
}
}
}
}
},
"404": {
"description": "未找到指定指标",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "未找到指标: metric_id=99999"
}
}
}
},
"400": {
"description": "请求参数错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "limit参数必须在1-10000之间"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "查询数据失败: [具体错误信息]"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SearchResponse": {
"type": "object",
"description": "搜索结果响应对象",
"required": [
"total",
"query",
"results"
],
"properties": {
"total": {
"type": "integer",
"description": "搜索结果总数",
"example": 50
},
"query": {
"type": "string",
"description": "查询关键词",
"example": "电解液 产量"
},
"results": {
"type": "array",
"description": "指标列表(按相关度评分降序)",
"items": {
"$ref": "#/components/schemas/MetricInfo"
}
}
}
},
"MetricInfo": {
"type": "object",
"description": "指标信息对象",
"required": [
"source",
"metric_id",
"metric_name",
"unit",
"frequency",
"category_path"
],
"properties": {
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"unit": {
"type": "string",
"description": "数据单位",
"example": "吨"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"category_path": {
"type": "string",
"description": "完整分类路径(用|分隔)",
"example": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量"
},
"description": {
"type": "string",
"description": "指标描述备注",
"example": ""
},
"score": {
"type": "number",
"description": "搜索相关度评分(仅搜索结果返回)",
"nullable": true,
"example": 15.8
}
}
},
"CategoryTreeResponse": {
"type": "object",
"description": "分类树响应对象",
"required": [
"source",
"total_metrics",
"tree"
],
"properties": {
"source": {
"type": "string",
"description": "数据源名称",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"total_metrics": {
"type": "integer",
"description": "总指标数量",
"example": 127509
},
"tree": {
"type": "array",
"description": "树的根节点列表",
"items": {
"$ref": "#/components/schemas/TreeNode"
}
}
}
},
"TreeNode": {
"type": "object",
"description": "树节点对象",
"required": [
"name",
"path",
"level",
"has_children"
],
"properties": {
"name": {
"type": "string",
"description": "节点名称",
"example": "钴"
},
"path": {
"type": "string",
"description": "节点完整路径,用竖线分隔",
"example": "钴|钴化合物|硫酸钴"
},
"level": {
"type": "integer",
"description": "节点层级深度(从1开始)",
"minimum": 1,
"example": 3
},
"has_children": {
"type": "boolean",
"description": "是否有子节点(用于前端判断是否可展开)",
"example": true
},
"children": {
"type": "array",
"description": "子节点列表(根据max_depth可能为空数组)",
"items": {
"$ref": "#/components/schemas/TreeNode"
}
},
"metrics": {
"type": "array",
"description": "该节点下的指标列表(通常只有叶子节点有)",
"items": {
"$ref": "#/components/schemas/TreeMetric"
}
}
}
},
"TreeMetric": {
"type": "object",
"description": "树节点中的指标信息",
"required": [
"metric_id",
"metric_name",
"source",
"frequency",
"unit"
],
"properties": {
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"unit": {
"type": "string",
"description": "指标单位",
"example": "吨"
},
"description": {
"type": "string",
"description": "指标描述",
"example": ""
}
}
},
"MetricDataResponse": {
"type": "object",
"description": "指标数据查询响应对象",
"required": [
"metric_id",
"metric_name",
"source",
"frequency",
"unit",
"data",
"total_count"
],
"properties": {
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"MYSTEEL"
],
"example": "SMM"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"unit": {
"type": "string",
"description": "数据单位",
"example": "吨"
},
"data": {
"type": "array",
"description": "时间序列数据点列表(按日期倒序)",
"items": {
"$ref": "#/components/schemas/DataPoint"
}
},
"total_count": {
"type": "integer",
"description": "符合条件的数据总条数",
"example": 120
}
}
},
"DataPoint": {
"type": "object",
"description": "单个数据点",
"required": [
"date",
"value"
],
"properties": {
"date": {
"type": "string",
"description": "日期,格式 YYYY-MM-DD",
"example": "2024-01-01"
},
"value": {
"type": "number",
"description": "数值(可能为null)",
"nullable": true,
"example": 1234.56
}
}
},
"ErrorResponse": {
"type": "object",
"description": "错误响应对象",
"required": [
"detail"
],
"properties": {
"detail": {
"type": "string",
"description": "错误详细信息",
"example": "未找到数据源 'XXX' 的树状数据"
}
}
}
},
"examples": {
"SMM完整树示例": {
"summary": "SMM完整树结构示例",
"value": {
"source": "SMM",
"total_metrics": 127509,
"tree": [
{
"name": "农业食品农资",
"path": "农业食品农资",
"level": 1,
"children": [
{
"name": "饲料",
"path": "农业食品农资|饲料",
"level": 2,
"children": []
}
]
},
{
"name": "小金属",
"path": "小金属",
"level": 1,
"children": [
{
"name": "钴",
"path": "小金属|钴",
"level": 2,
"children": [
{
"name": "钴化合物",
"path": "小金属|钴|钴化合物",
"level": 3,
"children": []
}
]
}
]
}
]
}
},
"Mysteel完整树示例": {
"summary": "Mysteel完整树结构示例",
"value": {
"source": "Mysteel",
"total_metrics": 272450,
"tree": [
{
"name": "钢铁产业",
"path": "钢铁产业",
"level": 1,
"children": [
{
"name": "原材料",
"path": "钢铁产业|原材料",
"level": 2,
"children": []
}
]
}
]
}
},
"节点查询示例": {
"summary": "节点查询返回示例",
"value": {
"name": "钴化合物",
"path": "小金属|钴|钴化合物",
"level": 3,
"children": [
{
"name": "硫酸钴",
"path": "小金属|钴|钴化合物|硫酸钴",
"level": 4,
"metrics": [
{
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"description": ""
}
]
}
]
}
}
}
}
}

View File

@@ -39,6 +39,13 @@ module.exports = {
priority: 30,
reuseExistingChunk: true,
},
// TradingView Lightweight Charts 单独分离(避免被压缩破坏)
lightweightCharts: {
test: /[\\/]node_modules[\\/]lightweight-charts[\\/]/,
name: 'lightweight-charts',
priority: 26,
reuseExistingChunk: true,
},
// 大型图表库分离echarts, d3, apexcharts 等)
charts: {
test: /[\\/]node_modules[\\/](echarts|echarts-for-react|apexcharts|react-apexcharts|recharts|d3|d3-.*)[\\/]/,
@@ -69,7 +76,7 @@ module.exports = {
},
// 日期/日历库
calendar: {
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
name: 'calendar-lib',
priority: 18,
reuseExistingChunk: true,
@@ -96,8 +103,43 @@ module.exports = {
moduleIds: 'deterministic',
// 最小化配置
minimize: true,
minimizer: [
...webpackConfig.optimization.minimizer,
],
};
// 配置 Terser 插件,保留 lightweight-charts 的方法名
const TerserPlugin = require('terser-webpack-plugin');
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map(plugin => {
if (plugin.constructor.name === 'TerserPlugin') {
const originalOptions = plugin.options || {};
const originalTerserOptions = originalOptions.terserOptions || {};
const originalMangle = originalTerserOptions.mangle || {};
// 只保留 TerserPlugin 有效的配置项
const validOptions = {
test: originalOptions.test,
include: originalOptions.include,
exclude: originalOptions.exclude,
extractComments: originalOptions.extractComments,
parallel: originalOptions.parallel,
minify: originalOptions.minify,
terserOptions: {
...originalTerserOptions,
keep_classnames: /^(IChartApi|ISeriesApi|Re)$/, // 保留 lightweight-charts 的类名
keep_fnames: /^(createChart|addLineSeries|addSeries)$/, // 保留关键方法名
mangle: {
...originalMangle,
reserved: ['createChart', 'addLineSeries', 'addSeries', 'IChartApi', 'ISeriesApi'],
},
},
};
return new TerserPlugin(validOptions);
}
return plugin;
});
// 生产环境禁用 source map 以加快构建(可节省 40-60% 时间)
webpackConfig.devtool = false;
} else {
@@ -161,13 +203,8 @@ module.exports = {
);
}
// 忽略 moment 的语言包(如果项目使用了 moment
webpackConfig.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
})
);
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
// 如果需要优化,可以只导入需要的语言包
// ============== Loader 优化 ==============
const babelLoaderRule = webpackConfig.module.rules.find(

View File

@@ -0,0 +1,918 @@
# Bytedesk客服系统 - 前端工程师集成手册
**版本**: v1.0
**最后更新**: 2025-01-07
**适用项目**: vf_react
**后端服务器**: http://43.143.189.195
---
## 📋 目录
- [1. 集成概述](#1-集成概述)
- [2. 快速开始5分钟集成](#2-快速开始5分钟集成)
- [3. 详细集成步骤](#3-详细集成步骤)
- [4. 配置说明](#4-配置说明)
- [5. 高级功能](#5-高级功能)
- [6. 样式定制](#6-样式定制)
- [7. 故障排查](#7-故障排查)
- [8. 常见问题FAQ](#8-常见问题faq)
- [9. 性能优化](#9-性能优化)
- [10. 安全注意事项](#10-安全注意事项)
---
## 1. 集成概述
### 1.1 什么是Bytedesk客服系统
Bytedesk是一个开源的在线客服系统为您的网站提供实时客户服务功能。本手册将指导您将Bytedesk客服Widget集成到vf_react项目中。
### 1.2 集成架构
```
┌────────────────────────────────────────────────────────────┐
│ vf_react前端项目 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ App.jsx │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ BytedeskWidget组件 │ │ │
│ │ │ - 动态加载客服脚本 │ │ │
│ │ │ - 显示悬浮客服图标 │ │ │
│ │ │ - 处理用户交互 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│ HTTP/WebSocket
┌────────────────────────────────────────────────────────────┐
│ Bytedesk后端服务 (43.143.189.195) │
│ - API接口: :9003 │
│ - WebSocket: :9885 │
│ - Nginx反向代理: :80 │
└────────────────────────────────────────────────────────────┘
```
### 1.3 集成特点
-**零侵入**: 不修改vf_react原有代码逻辑
-**即插即用**: 复制文件 + 修改配置即可使用
-**样式隔离**: 使用Shadow DOM不影响全局样式
-**异步加载**: 不阻塞页面渲染
-**跨页面**: 在所有页面显示客服图标
-**响应式**: 自动适配移动端和PC端
---
## 2. 快速开始5分钟集成
### 步骤1: 复制集成文件
`bytedesk-integration`文件夹复制到vf_react项目的`src/`目录下:
```bash
# 在vf_react项目根目录执行
cd D:\【Git】\vf_react
cp -r bytedesk-integration src/
```
文件结构:
```
vf_react/
├── src/
│ ├── bytedesk-integration/ # 客服集成文件夹
│ │ ├── components/
│ │ │ └── BytedeskWidget.jsx # 客服Widget组件
│ │ ├── config/
│ │ │ └── bytedesk.config.js # 配置文件
│ │ ├── App.jsx.example # 集成示例代码
│ │ ├── .env.bytedesk.example # 环境变量示例
│ │ └── 前端工程师集成手册.md # 本手册
│ ├── App.jsx # 您的主App文件
│ └── ...
└── package.json
```
### 步骤2: 配置环境变量
复制环境变量模板到项目根目录并配置:
```bash
# 复制模板
cp src/bytedesk-integration/.env.bytedesk.example .env.local
# 编辑配置文件
vim .env.local
```
**必需配置项**(在.env.local中:
```bash
# Bytedesk服务器地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织ID由管理员提供
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组ID由管理员提供
REACT_APP_BYTEDESK_SID=df_wg_aftersales
```
> **注意**: ORG和SID需要从管理员处获取或登录后台http://43.143.189.195/admin/查看。
### 步骤3: 集成到App.jsx
打开`src/App.jsx`,参考`App.jsx.example`添加以下代码:
```jsx
// 1. 导入组件和配置(在文件顶部添加)
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
// 2. 获取配置
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
{/* 您的原有代码保持不变 */}
{/* 3. 添加客服Widget在return的JSX最后添加 */}
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
</div>
);
}
export default App;
```
### 步骤4: 启动项目测试
```bash
# 安装依赖(如果需要)
npm install
# 启动开发服务器
npm start
```
打开浏览器,您应该在页面右下角看到客服图标(💬)。
---
## 3. 详细集成步骤
### 3.1 文件说明
#### BytedeskWidget.jsx
React组件负责加载和管理Bytedesk客服Widget。
**主要功能**:
- 动态加载客服脚本https://www.weiyuai.cn/embed/bytedesk-web.js
- 初始化客服Widget
- 生命周期管理(加载、卸载、清理)
- 错误处理
**Props**:
```typescript
interface BytedeskWidgetProps {
config: Object; // 配置对象(必需)
autoLoad?: boolean; // 是否自动加载默认true
onLoad?: (bytedesk) => void; // 加载成功回调
onError?: (error) => void; // 加载失败回调
}
```
#### bytedesk.config.js
配置文件,包含客服系统的所有配置项。
**主要函数**:
- `getBytedeskConfig()`: 获取基础配置
- `getBytedeskConfigWithUser(user)`: 获取带用户信息的配置
- `shouldShowCustomerService(pathname)`: 判断是否在当前页面显示客服
### 3.2 集成方式选择
根据您的需求,选择合适的集成方式:
#### 方式一: 全局集成(推荐)
**适用场景**: 所有页面都需要客服功能
```jsx
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
{/* 您的页面内容 */}
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
#### 方式二: 按页面显示
**适用场景**: 只在特定页面显示客服(如排除登录页、支付页)
```jsx
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
function App() {
const location = useLocation();
const bytedeskConfig = getBytedeskConfig();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<div className="App">
{/* 您的页面内容 */}
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
自定义页面规则(修改`bytedesk.config.js`:
```javascript
export const shouldShowCustomerService = (pathname) => {
// 在以下页面显示客服
const allowedPages = [
'/',
'/home',
'/products',
'/pricing',
];
// 在以下页面隐藏客服
const blockedPages = [
'/login',
'/register',
'/payment',
];
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
return allowedPages.some(page => pathname.startsWith(page));
};
```
#### 方式三: 带用户信息集成
**适用场景**: 需要将登录用户信息传递给客服端
```jsx
import { useContext } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext';
function App() {
const { user } = useContext(AuthContext);
const bytedeskConfig = getBytedeskConfigWithUser(user);
return (
<div className="App">
{/* 您的页面内容 */}
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
用户信息格式:
```javascript
const user = {
id: '12345', // 用户ID必需
name: '张三', // 用户名
email: 'user@example.com', // 邮箱
mobile: '13800138000', // 手机号
};
```
---
## 4. 配置说明
### 4.1 环境变量配置
`.env.local`文件中配置(项目根目录):
```bash
# ========== 必需配置 ==========
# 后端服务地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织ID
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组ID
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ========== 可选配置 ==========
# 客服类型 (2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2
# 语言 (zh-cn, en, ja, ko)
REACT_APP_BYTEDESK_LOCALE=zh-cn
# 图标位置 (bottom-right, bottom-left, top-right, top-left)
REACT_APP_BYTEDESK_PLACEMENT=bottom-right
# 图标边距(像素)
REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
REACT_APP_BYTEDESK_MARGIN_SIDE=20
# 主题模式 (system, light, dark)
REACT_APP_BYTEDESK_THEME_MODE=system
# 主题色
REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
# 自动弹出(不推荐)
REACT_APP_BYTEDESK_AUTO_POPUP=false
```
### 4.2 代码配置
`bytedesk.config.js`中直接修改:
```javascript
export const bytedeskConfig = {
// API服务地址
apiUrl: 'http://43.143.189.195',
htmlUrl: 'http://43.143.189.195/chat/',
// 客服图标位置
placement: 'bottom-right',
// 边距设置
marginBottom: 20,
marginSide: 20,
// 自动弹出
autoPopup: false,
// 语言设置
locale: 'zh-cn',
// 客服图标配置
bubbleConfig: {
show: true,
icon: '💬', // 可以使用emoji或图片URL
title: '在线客服',
subtitle: '点击咨询',
},
// 主题配置
theme: {
mode: 'system', // light | dark | system
backgroundColor: '#0066FF',
textColor: '#ffffff',
},
// 聊天配置
chatConfig: {
org: 'df_org_uid',
t: '2', // 2=人工客服, 1=机器人
sid: 'df_wg_aftersales',
},
};
```
---
## 5. 高级功能
### 5.1 多工作组支持
根据页面显示不同工作组的客服:
```javascript
// bytedesk.config.js
export const getBytedeskConfigByPath = (pathname) => {
const config = getBytedeskConfig();
// 根据路径选择工作组
if (pathname.startsWith('/sales')) {
return {
...config,
chatConfig: {
...config.chatConfig,
sid: 'df_wg_sales', // 销售组
},
};
} else if (pathname.startsWith('/support')) {
return {
...config,
chatConfig: {
...config.chatConfig,
sid: 'df_wg_support', // 技术支持组
},
};
}
return config; // 默认售后组
};
```
使用示例:
```jsx
import { useLocation } from 'react-router-dom';
import { getBytedeskConfigByPath } from './bytedesk-integration/config/bytedesk.config';
function App() {
const location = useLocation();
const bytedeskConfig = getBytedeskConfigByPath(location.pathname);
return (
<div className="App">
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
### 5.2 条件性显示
根据用户登录状态或角色显示客服:
```jsx
function App() {
const { user } = useContext(AuthContext);
const bytedeskConfig = getBytedeskConfig();
// 只为普通用户显示客服(管理员不显示)
const showBytedesk = user && user.role === 'customer';
return (
<div className="App">
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
### 5.3 事件回调
监听客服系统的加载状态:
```jsx
function App() {
const bytedeskConfig = getBytedeskConfig();
const handleLoad = (bytedesk) => {
console.log('客服系统加载成功', bytedesk);
// 可以在这里执行自定义逻辑
// 例如: 发送统计事件
};
const handleError = (error) => {
console.error('客服系统加载失败', error);
// 可以在这里显示降级方案
// 例如: 显示备用联系方式
};
return (
<div className="App">
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleLoad}
onError={handleError}
/>
</div>
);
}
```
### 5.4 自定义触发按钮
隐藏默认图标,使用自定义按钮:
```jsx
import { useState } from 'react';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
// 隐藏默认图标
const bytedeskConfig = {
...getBytedeskConfig(),
bubbleConfig: {
show: false, // 隐藏默认图标
},
};
return (
<div className="App">
{/* 自定义按钮 */}
<button
onClick={() => setShowBytedesk(true)}
className="custom-service-btn"
>
联系客服
</button>
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
---
## 6. 样式定制
### 6.1 修改主题色
在配置中修改主题色:
```javascript
// bytedesk.config.js
theme: {
mode: 'light',
backgroundColor: '#FF6600', // 您的品牌色
textColor: '#ffffff',
},
```
### 6.2 修改图标位置
```javascript
// bytedesk.config.js
placement: 'bottom-left', // 左下角
marginBottom: 30, // 距底部30px
marginSide: 30, // 距左侧30px
```
### 6.3 使用自定义图标
使用图片URL替换emoji:
```javascript
// bytedesk.config.js
bubbleConfig: {
show: true,
icon: 'https://yourdomain.com/images/service-icon.png',
title: '在线客服',
subtitle: '点击咨询',
},
```
### 6.4 样式不冲突
Bytedesk Widget使用Shadow DOM技术样式完全隔离不会影响您的全局CSS。
---
## 7. 故障排查
### 7.1 客服图标不显示
**可能原因**:
1. 环境变量未配置
2. 配置文件路径错误
3. 后端服务未启动
4. 脚本加载失败
**解决方案**:
```bash
# 1. 检查.env.local文件是否存在
ls -la .env.local
# 2. 检查环境变量是否加载
console.log(process.env.REACT_APP_BYTEDESK_API_URL);
# 3. 检查后端服务状态
curl http://43.143.189.195/api/health
# 4. 查看浏览器控制台错误
# 打开浏览器开发者工具 -> Console标签页
```
### 7.2 连接不上后端
**检查清单**:
```bash
# 1. 后端服务是否运行
# 联系后端工程师确认docker容器状态
# 2. 防火墙是否开放
# 确认80端口可访问
# 3. CORS配置
# 后端需要在.env.production中添加您的前端地址:
# BYTEDESK_CORS_ALLOWED_ORIGINS=http://your-frontend-domain.com
```
### 7.3 ORG或SID错误
**获取正确配置**:
1. 登录管理后台: http://43.143.189.195/admin/
2. 导航到"设置" -> "组织信息",复制`组织UID`
3. 导航到"客服管理" -> "工作组",复制`工作组ID`
4. 更新`.env.local`文件
5. 重启开发服务器: `npm start`
### 7.4 开发环境正常,生产环境异常
**检查清单**:
```bash
# 1. 确认生产环境的环境变量
# 查看构建时的配置
# 2. 检查CORS配置
# 后端需要添加生产域名到CORS白名单
# 3. 检查HTTPS/HTTP
# 如果前端使用HTTPS后端也应使用HTTPS
# 4. 查看生产环境日志
npm run build
# 检查构建产物中的配置
```
---
## 8. 常见问题FAQ
### Q1: 客服系统会影响页面性能吗?
**A**: 不会。客服脚本采用异步加载不会阻塞页面渲染。Widget总大小约50KBgzip后首次加载后会被浏览器缓存。
### Q2: 可以在移动端使用吗?
**A**: 可以。Bytedesk Widget完全响应式自动适配移动端和PC端。
### Q3: 是否支持离线消息?
**A**: 支持。用户在客服离线时发送的消息会被保存,客服上线后可以查看。
### Q4: 可以集成到React Native吗
**A**: BytedeskWidget是为Web设计的。React Native需要使用Bytedesk的原生SDK另外提供
### Q5: 如何隐藏特定页面的客服?
**A**: 使用`shouldShowCustomerService`函数见3.2节"方式二")。
### Q6: 可以同时配置多个工作组吗?
**A**: 可以。参考5.1节"多工作组支持"。
### Q7: 用户信息是否安全?
**A**: 是的。所有通信使用WebSocket加密传输用户信息不会被第三方获取。建议生产环境使用HTTPS。
### Q8: 是否需要付费?
**A**: Bytedesk社区版当前使用完全免费License有效期至2040年12月31日。
---
## 9. 性能优化
### 9.1 按需加载
只在需要时加载客服系统:
```jsx
import { useState, useEffect } from 'react';
function App() {
const [loadBytedesk, setLoadBytedesk] = useState(false);
// 延迟5秒加载页面渲染完成后
useEffect(() => {
const timer = setTimeout(() => {
setLoadBytedesk(true);
}, 5000);
return () => clearTimeout(timer);
}, []);
return (
<div className="App">
{/* 您的页面内容 */}
{loadBytedesk && (
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
)}
</div>
);
}
```
### 9.2 Lazy Import
使用React.lazy延迟导入组件
```jsx
import { lazy, Suspense } from 'react';
const BytedeskWidget = lazy(() => import('./bytedesk-integration/components/BytedeskWidget'));
function App() {
return (
<div className="App">
{/* 您的页面内容 */}
<Suspense fallback={null}>
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
</Suspense>
</div>
);
}
```
### 9.3 缓存优化
客服脚本会自动被浏览器缓存,无需额外配置。
---
## 10. 安全注意事项
### 10.1 环境变量安全
```bash
# ❌ 错误: 不要在代码中硬编码配置
const config = {
apiUrl: 'http://43.143.189.195',
org: 'df_org_uid',
};
# ✅ 正确: 使用环境变量
const config = {
apiUrl: process.env.REACT_APP_BYTEDESK_API_URL,
org: process.env.REACT_APP_BYTEDESK_ORG,
};
```
### 10.2 敏感信息保护
```javascript
// ❌ 不要传递敏感信息
const user = {
id: '12345',
password: 'user-password', // 不要传递密码
creditCard: '1234-5678', // 不要传递信用卡
};
// ✅ 只传递必要信息
const user = {
id: '12345',
name: '张三',
email: 'user@example.com',
};
```
### 10.3 HTTPS使用
生产环境强烈建议使用HTTPS:
```bash
# 开发环境
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 生产环境
REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
```
### 10.4 内容安全策略CSP
如果您的项目使用CSP需要允许以下域名
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://www.weiyuai.cn;
connect-src 'self' http://43.143.189.195;
img-src 'self' data: http://43.143.189.195;
"/>
```
---
## 11. 获取帮助
### 11.1 联系方式
- **技术支持**: 访问 http://43.143.189.195/chat/ 在线咨询
- **管理员**: 联系您的项目管理员获取ORG和SID
- **后端工程师**: 联系后端团队确认服务器状态
### 11.2 日志查看
```javascript
// 在浏览器控制台查看Bytedesk日志
// 日志前缀为 [Bytedesk]
// 示例:
[Bytedesk] 开始加载客服Widget...
[Bytedesk] Widget脚本加载成功
[Bytedesk] 初始化Widget
[Bytedesk] Widget初始化成功
```
### 11.3 调试技巧
```javascript
// 1. 检查配置是否正确
console.log('Bytedesk配置:', getBytedeskConfig());
// 2. 检查环境变量
console.log('API URL:', process.env.REACT_APP_BYTEDESK_API_URL);
console.log('ORG:', process.env.REACT_APP_BYTEDESK_ORG);
console.log('SID:', process.env.REACT_APP_BYTEDESK_SID);
// 3. 检查Widget是否加载
console.log('BytedeskWeb对象:', window.BytedeskWeb);
```
---
## 12. 版本历史
| 版本 | 日期 | 更新内容 |
|------|------|---------|
| v1.0 | 2025-01-07 | 初始版本,支持基础集成功能 |
---
## 13. 附录
### 13.1 完整配置示例
```javascript
// bytedesk.config.js - 完整配置
export const bytedeskConfig = {
apiUrl: 'http://43.143.189.195',
htmlUrl: 'http://43.143.189.195/chat/',
placement: 'bottom-right',
marginBottom: 20,
marginSide: 20,
autoPopup: false,
locale: 'zh-cn',
bubbleConfig: {
show: true,
icon: '💬',
title: '在线客服',
subtitle: '点击咨询',
},
theme: {
mode: 'system',
backgroundColor: '#0066FF',
textColor: '#ffffff',
},
chatConfig: {
org: 'df_org_uid',
t: '2',
sid: 'df_wg_aftersales',
},
};
```
### 13.2 文件清单
集成所需的所有文件:
```
bytedesk-integration/
├── components/
│ └── BytedeskWidget.jsx # React组件必需
├── config/
│ └── bytedesk.config.js # 配置文件(必需)
├── App.jsx.example # 集成示例(参考)
├── .env.bytedesk.example # 环境变量示例(参考)
└── 前端工程师集成手册.md # 本手册(参考)
```
---
**祝您集成顺利!**
如有任何问题,请随时联系技术支持。

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

@@ -0,0 +1,576 @@
# 订阅支付系统重新设计方案
## 📊 问题分析
### 现有系统的问题
1. **价格配置混乱**
- 季付和月付价格相同(配置错误)
- `monthly_price``yearly_price` 字段命名不清晰
- 缺少季付、半年付等周期的价格配置
2. **升级逻辑复杂且不合理**
- 计算剩余价值折算(按天计算 `remaining_value`
- 用户难以理解升级价格
- 续费用户和新用户价格不一致
- 逻辑复杂,容易出错
3. **按钮文案不清晰**
- 已订阅用户应显示"续费 Pro"/"续费 Max"
- 而不是"升级至 Pro"/"切换至 Pro"
4. **数据库表设计问题**
- `SubscriptionUpgrade` 表记录升级,但逻辑过于复杂
- `PaymentOrder` 表缺少必要字段
- 价格配置分散在多个字段
---
## ✨ 新设计方案
### 核心原则
1. **简化续费逻辑**: **续费用户与新用户价格完全一致**,不做任何折算
2. **清晰的价格体系**: 每个套餐每个周期都有明确的价格
3. **统一的用户体验**: 无论是新购还是续费,价格透明一致
4. **独立的订阅记录**: 每次支付都创建新的订阅记录(历史可追溯)
---
## 📐 数据库表设计
### 1. `subscription_plans` - 订阅套餐表(重构)
```sql
CREATE TABLE subscription_plans (
id INT PRIMARY KEY AUTO_INCREMENT,
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
description TEXT COMMENT '套餐描述',
features JSON COMMENT '功能列表',
-- 价格配置(所有周期价格)
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
-- 状态字段
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
display_order INT DEFAULT 0 COMMENT '展示顺序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_plan_code (plan_code),
INDEX idx_active_order (is_active, display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
```
**示例数据**:
```sql
INSERT INTO subscription_plans (plan_code, plan_name, description, price_monthly, price_quarterly, price_semiannual, price_yearly) VALUES
('pro', 'Pro 专业版', '为专业投资者打造', 299.00, 799.00, 1499.00, 2699.00),
('max', 'Max 旗舰版', '旗舰级体验', 599.00, 1599.00, 2999.00, 5399.00);
```
---
### 2. `user_subscriptions` - 用户订阅记录表(重构)
```sql
CREATE TABLE user_subscriptions (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
-- 订阅基本信息
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max',
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
-- 订阅时间
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
-- 订阅状态
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
-- 支付信息
payment_order_id INT COMMENT '关联的支付订单ID',
paid_amount DECIMAL(10,2) NOT NULL COMMENT '实际支付金额',
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
-- 订阅类型
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
-- 自动续费
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_subscription_id (subscription_id),
INDEX idx_user_current (user_id, is_current),
INDEX idx_status (status),
INDEX idx_end_date (end_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
```
**设计说明**:
- 每次支付都创建新的订阅记录
- 通过 `is_current` 标识当前生效的订阅
- 支持订阅历史追溯
- 续费时记录 `previous_subscription_id` 形成订阅链
---
### 3. `payment_orders` - 支付订单表(重构)
```sql
CREATE TABLE payment_orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
user_id INT NOT NULL COMMENT '用户ID',
-- 订阅信息
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
-- 价格信息
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
-- 优惠码
promo_code_id INT COMMENT '优惠码ID',
promo_code VARCHAR(50) COMMENT '优惠码',
-- 支付信息
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
transaction_id VARCHAR(64) COMMENT '第三方交易号',
qr_code_url TEXT COMMENT '支付二维码URL',
-- 订单状态
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
-- 时间信息
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
paid_at TIMESTAMP NULL COMMENT '支付时间',
expired_at TIMESTAMP NULL COMMENT '过期时间',
-- 备注
remark TEXT COMMENT '备注信息',
INDEX idx_order_no (order_no),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
```
---
### 4. `promo_codes` - 优惠码表(保持不变,微调)
```sql
CREATE TABLE promo_codes (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码',
description VARCHAR(200) COMMENT '描述',
-- 折扣类型
discount_type VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比), fixed_amount(固定金额)',
discount_value DECIMAL(10,2) NOT NULL COMMENT '折扣值',
-- 适用范围
applicable_plans JSON COMMENT '适用套餐: ["pro", "max"] 或 null(全部)',
applicable_cycles JSON COMMENT '适用周期: ["monthly", "yearly"] 或 null(全部)',
min_amount DECIMAL(10,2) COMMENT '最低消费金额',
-- 使用限制
max_total_uses INT COMMENT '最大使用次数(总)',
max_uses_per_user INT DEFAULT 1 COMMENT '每用户最大使用次数',
current_uses INT DEFAULT 0 COMMENT '当前使用次数',
-- 有效期
valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
valid_until TIMESTAMP NULL COMMENT '过期时间',
-- 状态
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_code (code),
INDEX idx_active (is_active),
INDEX idx_valid_period (valid_from, valid_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
```
---
### 5. `promo_code_usage` - 优惠码使用记录表(保持不变)
```sql
CREATE TABLE promo_code_usage (
id INT PRIMARY KEY AUTO_INCREMENT,
promo_code_id INT NOT NULL,
user_id INT NOT NULL,
order_id INT NOT NULL,
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_promo_code (promo_code_id),
INDEX idx_user_id (user_id),
INDEX idx_order_id (order_id),
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
```
---
### 6. 删除不必要的表
**删除 `subscription_upgrades` 表** - 不再需要复杂的升级逻辑
---
## 💡 业务逻辑设计
### 1. 价格计算逻辑(简化版)
```python
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None):
"""
计算订阅价格(新购和续费价格完全一致)
Args:
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
Returns:
dict: {
'plan_code': 'pro',
'billing_cycle': 'yearly',
'original_price': 2699.00,
'discount_amount': 0,
'final_amount': 2699.00,
'promo_code': None,
'promo_error': None
}
"""
# 1. 查询套餐价格
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
if not plan:
return {'error': '套餐不存在'}
# 2. 获取对应周期的价格
price_field = f'price_{billing_cycle}'
original_price = getattr(plan, price_field, 0)
if original_price <= 0:
return {'error': '价格配置错误'}
result = {
'plan_code': plan_code,
'plan_name': plan.plan_name,
'billing_cycle': billing_cycle,
'original_price': float(original_price),
'discount_amount': 0,
'final_amount': float(original_price),
'promo_code': None,
'promo_error': None
}
# 3. 应用优惠码(如果有)
if promo_code:
promo, error = validate_promo_code(promo_code, plan_code, billing_cycle, original_price, user_id)
if promo:
discount = calculate_discount(promo, original_price)
result['discount_amount'] = float(discount)
result['final_amount'] = float(original_price - discount)
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
```
**关键点**:
- ✅ 不再计算 `remaining_value`(剩余价值)
- ✅ 不再区分新购/续费价格
- ✅ 逻辑简单,易于维护
- ✅ 用户体验清晰透明
---
### 2. 创建订单逻辑
```python
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None):
"""
创建订阅支付订单
"""
# 1. 计算价格
price_result = calculate_subscription_price(plan_code, billing_cycle, promo_code)
if 'error' in price_result:
return {'success': False, 'error': price_result['error']}
# 2. 判断是新购还是续费
current_sub = get_current_subscription(user_id)
subscription_type = 'new'
if current_sub and current_sub.plan_code in ['pro', 'max']:
subscription_type = 'renew'
# 3. 创建支付订单
order = PaymentOrder(
order_no=generate_order_no(user_id),
user_id=user_id,
plan_code=plan_code,
billing_cycle=billing_cycle,
subscription_type=subscription_type,
original_price=price_result['original_price'],
discount_amount=price_result['discount_amount'],
final_amount=price_result['final_amount'],
promo_code=promo_code,
status='pending',
expired_at=datetime.now() + timedelta(minutes=30)
)
db.session.add(order)
db.session.commit()
# 4. 生成支付二维码(微信支付)
qr_code_url = generate_wechat_qr_code(order)
order.qr_code_url = qr_code_url
db.session.commit()
return {'success': True, 'order': order.to_dict()}
```
---
### 3. 支付成功后的订阅激活逻辑
```python
def activate_subscription_after_payment(order_id):
"""
支付成功后激活订阅
"""
order = PaymentOrder.query.get(order_id)
if not order or order.status != 'paid':
return {'success': False, 'error': '订单状态错误'}
user_id = order.user_id
plan_code = order.plan_code
billing_cycle = order.billing_cycle
# 1. 计算订阅周期
cycle_days = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
days = cycle_days.get(billing_cycle, 30)
# 2. 获取当前订阅
current_sub = UserSubscription.query.filter_by(
user_id=user_id,
is_current=True
).first()
# 3. 计算开始和结束时间
now = datetime.now()
if current_sub and current_sub.end_date > now:
# 续费:从当前订阅结束时间开始
start_date = current_sub.end_date
else:
# 新购:从当前时间开始
start_date = now
end_date = start_date + timedelta(days=days)
# 4. 创建新订阅记录
new_subscription = UserSubscription(
user_id=user_id,
subscription_id=generate_subscription_id(),
plan_code=plan_code,
billing_cycle=billing_cycle,
start_date=start_date,
end_date=end_date,
status='active',
is_current=True,
payment_order_id=order.id,
paid_amount=order.final_amount,
original_price=order.original_price,
discount_amount=order.discount_amount,
subscription_type=order.subscription_type,
previous_subscription_id=current_sub.subscription_id if current_sub else None
)
# 5. 将旧订阅标记为非当前
if current_sub:
current_sub.is_current = False
db.session.add(new_subscription)
db.session.commit()
return {'success': True, 'subscription': new_subscription.to_dict()}
```
**关键特性**:
- ✅ 续费时从**当前订阅结束时间**开始,避免浪费
- ✅ 每次支付都创建新的订阅记录
- ✅ 保留历史订阅记录(通过 `previous_subscription_id` 形成链)
- ✅ 逻辑清晰,易于理解
---
### 4. 按钮文案逻辑
```python
def get_subscription_button_text(user, plan_code, billing_cycle):
"""
获取订阅按钮文字
Args:
user: 用户对象
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期
Returns:
str: 按钮文字
"""
current_sub = get_current_subscription(user.id)
# 1. 如果没有订阅或订阅已过期
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
return f"选择 {get_plan_display_name(plan_code)}"
# 2. 如果是当前套餐且周期相同
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
return f"续费 {get_plan_display_name(plan_code)}"
# 3. 如果是当前套餐但周期不同
if current_sub.plan_code == plan_code:
return f"切换至{get_cycle_display_name(billing_cycle)}"
# 4. 如果是不同套餐
return f"选择 {get_plan_display_name(plan_code)}"
def get_plan_display_name(plan_code):
names = {'pro': 'Pro 专业版', 'max': 'Max 旗舰版'}
return names.get(plan_code, plan_code)
def get_cycle_display_name(billing_cycle):
names = {
'monthly': '月付',
'quarterly': '季付',
'semiannual': '半年付',
'yearly': '年付'
}
return names.get(billing_cycle, billing_cycle)
```
**示例**:
- 免费用户看 Pro 年付: "选择 Pro 专业版"
- Pro 月付用户看 Pro 年付: "切换至年付"
- Pro 年付用户看 Pro 年付: "续费 Pro 专业版"
- Pro 用户看 Max 年付: "选择 Max 旗舰版"
---
## 📊 价格配置示例
### Pro 专业版价格设定
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|---------|------|------|------|---------|
| 月付 | ¥299 | - | - | ¥299 |
| 季付(3个月) | ¥799 | ¥897 | 11% | ¥266 |
| 半年付(6个月) | ¥1499 | ¥1794 | 16% | ¥250 |
| 年付(12个月) | ¥2699 | ¥3588 | 25% | ¥225 |
### Max 旗舰版价格设定
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|---------|------|------|------|---------|
| 月付 | ¥599 | - | - | ¥599 |
| 季付(3个月) | ¥1599 | ¥1797 | 11% | ¥533 |
| 半年付(6个月) | ¥2999 | ¥3594 | 17% | ¥500 |
| 年付(12个月) | ¥5399 | ¥7188 | 25% | ¥450 |
---
## 🔄 迁移方案
### 数据迁移 SQL
参见 `database_migration.sql`
### 代码迁移步骤
1. **备份现有数据库**
2. **执行数据库迁移 SQL**
3. **更新数据库模型** (`models.py`)
4. **更新价格计算逻辑** (`calculate_price.py`)
5. **更新 API 路由** (`routes.py`)
6. **更新前端组件** (`SubscriptionContentNew.tsx`)
7. **测试完整流程**
8. **灰度发布**
---
## ✅ 优势总结
### 相比旧系统的改进
1. **价格透明** - 续费用户和新用户价格完全一致
2. **逻辑简化** - 不再计算剩余价值,代码减少 50%+
3. **易于理解** - 用户体验更清晰
4. **灵活扩展** - 轻松添加新的计费周期
5. **历史追溯** - 完整的订阅历史记录
6. **数据完整** - 每次支付都有完整的记录
### 用户体验改进
1. **按钮文案清晰** - "续费 Pro"/"选择 Pro"明确表达意图
2. **价格一致性** - 所有用户看到的价格都一样
3. **无隐藏费用** - 不会因为"升级折算"产生困惑
4. **透明计费** - 支付金额 = 显示价格 - 优惠码折扣
---
## 📝 后续优化建议
1. **自动续费** - 到期前自动扣款续费
2. **订阅提醒** - 到期前 7 天、3 天、1 天发送通知
3. **订阅暂停** - 允许用户暂停订阅
4. **订阅降级** - 从 Max 降级到 Pro当前周期结束后生效
5. **发票管理** - 支持开具电子发票
6. **支付方式扩展** - 支持支付宝、银行卡等
---
**设计时间**: 2025-11-19
**设计者**: Claude Code
**版本**: v2.0.0

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,841 +0,0 @@
# PostHog 事件追踪实施总结
## ✅ 已完成的追踪
### 1. Home 页面(首页/落地页)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `LANDING_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `is_authenticated` - 是否已登录
- `user_id` - 用户ID如果已登录
#### 🎯 功能卡片点击
- **事件**: `FEATURE_CARD_CLICKED`
- **触发时机**: 用户点击任何功能卡片
- **属性**:
- `feature_id` - 功能IDnews-catalyst, concepts, stocks, etc.
- `feature_title` - 功能标题
- `feature_url` - 目标URL
- `is_featured` - 是否为推荐功能(新闻中心为 true
- `link_type` - 链接类型internal/external
**追踪的6个核心功能**:
1. **新闻中心** (`news-catalyst`) - 推荐功能,黄色边框
2. **概念中心** (`concepts`)
3. **个股信息汇总** (`stocks`)
4. **涨停板块分析** (`limit-analyse`)
5. **个股罗盘** (`company`)
6. **模拟盘交易** (`trading-simulation`)
---
### 2. StockOverview 页面(个股中心)✅ 已完成
**注意**:个股中心页面已完全实现 PostHog 追踪,通过 `src/views/StockOverview/hooks/useStockOverviewEvents.js` Hook。
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `STOCK_OVERVIEW_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
#### 📊 市场统计数据查看
- **事件**: `STOCK_LIST_VIEWED`
- **触发时机**: 加载市场统计数据
- **属性**:
- `total_market_cap` - 总市值
- `total_volume` - 总成交量
- `rising_stocks` - 上涨股票数
- `falling_stocks` - 下跌股票数
- `data_date` - 数据日期
#### 🔍 搜索追踪
- **事件**: `SEARCH_INITIATED` / `STOCK_SEARCHED`
- **触发时机**: 用户输入搜索、完成搜索
- **属性**:
- `query` - 搜索关键词
- `result_count` - 搜索结果数量
- `has_results` - 是否有结果
- `context` - 固定为 'stock_overview'
#### 🎯 搜索结果点击
- **事件**: `SEARCH_RESULT_CLICKED`
- **触发时机**: 用户点击搜索结果
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `exchange` - 交易所
- `position` - 在搜索结果中的位置
- `context` - 固定为 'stock_overview'
#### 🔥 概念卡片点击
- **事件**: `CONCEPT_CLICKED`
- **触发时机**: 用户点击热门概念卡片
- **属性**:
- `concept_name` - 概念名称
- `concept_code` - 概念代码
- `change_percent` - 涨跌幅
- `stock_count` - 股票数量
- `rank` - 排名
- `source` - 固定为 'daily_hot_concepts'
#### 🏷️ 概念股票标签点击
- **事件**: `CONCEPT_STOCK_CLICKED`
- **触发时机**: 点击概念下的股票标签
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `concept_name` - 所属概念
- `source` - 固定为 'daily_hot_concepts_tag'
#### 📊 热力图股票点击
- **事件**: `STOCK_CLICKED`
- **触发时机**: 点击热力图中的股票
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `change_percent` - 涨跌幅
- `market_cap_range` - 市值区间
- `source` - 固定为 'market_heatmap'
#### 📅 日期选择变化
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户选择不同的交易日期
- **属性**:
- `filter_type` - 固定为 'date'
- `filter_value` - 新选择的日期
- `previous_value` - 之前的日期
- `context` - 固定为 'stock_overview'
**实施方式**: Custom Hook (`useStockOverviewEvents.js`) 已集成
---
### 3. Concept 页面(概念中心)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `CONCEPT_CENTER_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
#### 🔍 搜索查询
- **事件**: `SEARCH_QUERY_SUBMITTED`
- **触发时机**: 用户搜索概念
- **属性**:
- `query` - 搜索关键词
- `category` - 固定为 'concept'
- `result_count` - 搜索结果数量
- `has_results` - 是否有结果
#### 🎚️ 筛选追踪
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户更改筛选条件
- **属性**:
- `filter_type` - 筛选类型sort/date
- `filter_value` - 筛选值
- `previous_value` - 之前的值
- `context` - 固定为 'concept_center'
**支持的筛选类型**:
1. **排序** (`sort`): 涨跌幅/相关度/股票数量/概念名称
2. **日期范围** (`date`): 选择交易日期
#### 🎯 概念卡片点击
- **事件**: `CONCEPT_CLICKED`
- **触发时机**: 用户点击概念卡片
- **属性**:
- `concept_id` - 概念ID
- `concept_name` - 概念名称
- `change_percent` - 涨跌幅
- `stock_count` - 股票数量
- `position` - 在列表中的位置
- `source` - 固定为 'concept_center_list'
#### 👀 查看个股
- **事件**: `CONCEPT_STOCKS_VIEWED`
- **触发时机**: 用户点击"查看个股"按钮
- **属性**:
- `concept_name` - 概念名称
- `stock_count` - 股票数量
- `source` - 固定为 'concept_center'
#### 🏷️ 概念股票点击
- **事件**: `CONCEPT_STOCK_CLICKED`
- **触发时机**: 点击概念股票表格中的股票
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `concept_name` - 所属概念
- `source` - 固定为 'concept_center_stock_table'
#### 📊 历史时间轴查看
- **事件**: `CONCEPT_TIMELINE_VIEWED`
- **触发时机**: 用户点击"历史时间轴"按钮
- **属性**:
- `concept_id` - 概念ID
- `concept_name` - 概念名称
- `source` - 固定为 'concept_center'
#### 📄 翻页追踪
- **事件**: `NEWS_LIST_VIEWED`
- **触发时机**: 用户翻页
- **属性**:
- `page` - 页码
- `filters` - 当前筛选条件
- `sort` - 排序方式
- `has_query` - 是否有搜索词
- `date` - 日期
- `context` - 固定为 'concept_center'
#### 🔄 视图模式切换
- **事件**: `VIEW_MODE_CHANGED`
- **触发时机**: 用户切换网格/列表视图
- **属性**:
- `view_mode` - 新视图模式grid/list
- `previous_mode` - 之前的模式
- `context` - 固定为 'concept_center'
---
### 4. Company 页面(公司详情/个股罗盘)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `COMPANY_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `stock_code` - 当前查看的股票代码
#### 🔍 股票搜索
- **事件**: `STOCK_SEARCHED`
- **触发时机**: 用户输入股票代码并查询
- **属性**:
- `query` - 搜索的股票代码
- `stock_code` - 股票代码
- `previous_stock_code` - 之前查看的股票代码
- `context` - 固定为 'company_page'
#### 🔄 Tab 切换
- **事件**: `TAB_CHANGED`
- **触发时机**: 用户切换不同的 Tab
- **属性**:
- `tab_index` - Tab 索引0-3
- `tab_name` - Tab 名称(公司概览/股票行情/财务全景/盈利预测)
- `previous_tab_index` - 之前的 Tab 索引
- `stock_code` - 当前股票代码
- `context` - 固定为 'company_page'
**支持的 Tab**:
1. **公司概览** (index 0): 公司基本信息
2. **股票行情** (index 1): 实时行情数据
3. **财务全景** (index 2): 财务报表分析
4. **盈利预测** (index 3): 盈利预测数据
#### ⭐ 自选股管理
- **事件**: `WATCHLIST_ADDED` / `WATCHLIST_REMOVED`
- **触发时机**: 用户添加/移除自选股
- **属性**:
- `stock_code` - 股票代码
- `source` - 固定为 'company_page'
---
### 5. Community 页面(新闻催化分析)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `COMMUNITY_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `has_hot_events` - 是否有热点事件
- `has_keywords` - 是否有热门关键词
#### 🔍 搜索追踪
- **事件**: `SEARCH_QUERY_SUBMITTED`
- **触发时机**: 用户输入搜索关键词
- **属性**:
- `query` - 搜索关键词
- `category` - 分类(固定为 'news'
- `previous_query` - 上一次搜索词
#### 🎚️ 筛选追踪
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户更改筛选条件
- **属性**:
- `filter_type` - 筛选类型sort/importance/date_range/industry
- `filter_value` - 筛选值
- `previous_value` - 上一次的值
**支持的筛选类型**:
1. **排序** (`sort`): 最新/最热/重要性
2. **重要性** (`importance`): 全部/高/中/低
3. **时间范围** (`date_range`): 今天/近7天/近30天
4. **行业** (`industry`): 各行业代码
#### 🗞️ 新闻点击追踪
- **事件**: `NEWS_ARTICLE_CLICKED`
- **触发时机**: 用户点击新闻事件
- **属性**:
- `event_id` - 事件ID
- `event_title` - 事件标题
- `importance` - 重要性等级
- `source` - 来源(固定为 'community_page'
- `has_stocks` - 是否包含相关股票
- `has_concepts` - 是否包含相关概念
#### 📖 详情查看追踪
- **事件**: `NEWS_DETAIL_OPENED`
- **触发时机**: 用户点击"查看详情"
- **属性**:
- `event_id` - 事件ID
- `source` - 来源(固定为 'community_page'
#### 📄 翻页追踪
- **事件**: `NEWS_LIST_VIEWED`
- **触发时机**: 用户翻页
- **属性**:
- `page` - 页码
- `filters` - 当前筛选条件
- `sort` - 排序方式
- `importance` - 重要性
- `has_query` - 是否有搜索词
---
## 🛠️ 实施方式
### 方案Custom Hook 集成(推荐)
**优势**:
- ✅ 集中管理,易于维护
- ✅ 自动追踪,无需修改组件
- ✅ 符合关注点分离原则
- ✅ 便于测试和调试
### 修改的文件
#### 0. `src/views/StockOverview/hooks/useStockOverviewEvents.js` ✅
**文件已存在**,无需修改。已完整实现个股中心的所有追踪事件。
#### 1. `src/views/Concept/hooks/useConceptEvents.js`
**新建 Hook 文件**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**提供的追踪函数**:
- `trackConceptSearched()` - 搜索概念
- `trackFilterApplied()` - 筛选变化
- `trackConceptClicked()` - 概念点击
- `trackConceptStocksViewed()` - 查看个股
- `trackConceptStockClicked()` - 点击概念股票
- `trackConceptTimelineViewed()` - 历史时间轴
- `trackPageChange()` - 翻页
- `trackViewModeChanged()` - 视图切换
#### 2. `src/views/Company/hooks/useCompanyEvents.js`
**新建 Hook 文件**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**提供的追踪函数**:
- `trackStockSearched()` - 股票搜索
- `trackTabChanged()` - Tab 切换
- `trackWatchlistAdded()` - 加入自选
- `trackWatchlistRemoved()` - 移除自选
#### 3. `src/views/Company/index.js`
**添加的导入**:
```javascript
import { useCompanyEvents } from './hooks/useCompanyEvents';
```
**添加的 Hook**:
```javascript
const {
trackStockSearched,
trackTabChanged,
trackWatchlistAdded,
trackWatchlistRemoved,
} = useCompanyEvents({ stockCode });
```
**添加的 State**:
```javascript
const [currentTabIndex, setCurrentTabIndex] = useState(0);
```
**修改的函数**:
1. **`handleSearch`**: 追踪股票搜索
2. **`handleWatchlistToggle`**: 追踪自选股添加/移除
3. **Tabs `onChange`**: 追踪 Tab 切换
#### 4. `src/views/Concept/index.js`
**添加的导入**:
```javascript
import { useConceptEvents } from './hooks/useConceptEvents';
```
**添加的 Hook**:
```javascript
const {
trackConceptSearched,
trackFilterApplied,
trackConceptClicked,
trackConceptStocksViewed,
trackConceptStockClicked,
trackConceptTimelineViewed,
trackPageChange,
trackViewModeChanged,
} = useConceptEvents({ navigate });
```
**修改的函数**:
1. **`handleSearch`**: 追踪搜索查询
2. **`handleSortChange`**: 追踪排序变化
3. **`handleDateChange`**: 追踪日期变化
4. **`handlePageChange`**: 追踪翻页
5. **`handleConceptClick`**: 追踪概念点击
6. **`handleViewStocks`**: 追踪查看个股
7. **`handleViewContent`**: 追踪历史时间轴
8. **视图切换按钮**: 追踪网格/列表切换
#### 3. `src/views/Home/HomePage.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { ACQUISITION_EVENTS } from '../../lib/constants';
```
**添加的 Hook**:
```javascript
const { track } = usePostHogTrack();
```
**添加的 useEffect**(页面浏览追踪):
```javascript
useEffect(() => {
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
is_authenticated: isAuthenticated,
user_id: user?.id || null,
});
}, [track, isAuthenticated, user?.id]);
```
**修改的函数**:
- **`handleProductClick`**: 从接收 URL 改为接收完整 feature 对象,添加追踪逻辑
**修改后的代码**:
```javascript
const handleProductClick = useCallback((feature) => {
// 🎯 PostHog 追踪:功能卡片点击
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
feature_id: feature.id,
feature_title: feature.title,
feature_url: feature.url,
is_featured: feature.featured || false,
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
});
// 原有导航逻辑
if (feature.url.startsWith('http')) {
window.open(feature.url, '_blank');
} else {
navigate(feature.url);
}
}, [track, navigate]);
```
**更新的 onClick 事件**:
```javascript
// 从
onClick={() => handleProductClick(coreFeatures[0].url)}
// 改为
onClick={() => handleProductClick(coreFeatures[0])}
```
#### 1. `src/views/Community/hooks/useEventFilters.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**添加的Hook**:
```javascript
const { track } = usePostHogTrack();
```
**修改的函数**:
1. **`updateFilters`**: 追踪搜索和筛选
2. **`handlePageChange`**: 追踪翻页
3. **`handleEventClick`**: 追踪新闻点击
4. **`handleViewDetail`**: 追踪详情查看
#### 2. `src/views/Community/index.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../lib/constants';
```
**添加的Hook**:
```javascript
const { track } = usePostHogTrack();
```
**添加的useEffect**:
```javascript
useEffect(() => {
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
has_hot_events: hotEvents && hotEvents.length > 0,
has_keywords: popularKeywords && popularKeywords.length > 0,
});
}, [track]);
```
---
## 📊 追踪效果示例
### 用户行为路径示例
**首页转化路径**:
```
1. 游客访问首页
→ 触发: LANDING_PAGE_VIEWED
→ 属性: { is_authenticated: false, user_id: null }
2. 点击"新闻中心"功能卡片
→ 触发: FEATURE_CARD_CLICKED
→ 属性: { feature_id: "news-catalyst", feature_title: "新闻中心", is_featured: true, link_type: "internal" }
3. 进入 Community 页面
→ 触发: COMMUNITY_PAGE_VIEWED
```
**Community 页面行为路径**:
```
1. 用户进入 Community 页面
→ 触发: COMMUNITY_PAGE_VIEWED
2. 用户搜索 "人工智能"
→ 触发: SEARCH_QUERY_SUBMITTED
→ 属性: { query: "人工智能", category: "news" }
3. 用户筛选 "重要性:高"
→ 触发: SEARCH_FILTER_APPLIED
→ 属性: { filter_type: "importance", filter_value: "high" }
4. 用户点击第一条新闻
→ 触发: NEWS_ARTICLE_CLICKED
→ 属性: { event_id: "123", event_title: "...", importance: "high", source: "community_page" }
5. 用户翻到第2页
→ 触发: NEWS_LIST_VIEWED
→ 属性: { page: 2, filters: { sort: "new", importance: "high", has_query: true } }
6. 用户点击"查看详情"
→ 触发: NEWS_DETAIL_OPENED
→ 属性: { event_id: "456", source: "community_page" }
```
---
## 🧪 测试方法
### 1. 使用 Redux DevTools
1. 打开应用:`npm start`
2. 打开浏览器 Redux DevTools
3. 筛选 `posthog/trackEvent` actions
4. 执行各种操作
5. 查看追踪的事件和属性
### 2. 控制台日志
开发环境下PostHog 会自动输出日志:
```
📍 Event tracked: Community Page Viewed { timestamp: "...", has_hot_events: true }
📍 Event tracked: Search Query Submitted { query: "人工智能", category: "news" }
📍 Event tracked: Search Filter Applied { filter_type: "importance", filter_value: "high" }
```
### 3. PostHog Dashboard
1. 登录 PostHog 后台
2. 查看 "Events" 页面
3. 筛选 Community 相关事件:
- `Community Page Viewed`
- `Search Query Submitted`
- `Search Filter Applied`
- `News Article Clicked`
- `News List Viewed`
---
## 📈 数据分析建议
### 1. 搜索行为分析
**问题**: 用户最常搜索什么?
**方法**:
- 筛选 `SEARCH_QUERY_SUBMITTED` 事件
-`query` 属性分组
- 查看 Top 关键词
### 2. 筛选偏好分析
**问题**: 用户更喜欢什么排序方式?
**方法**:
- 筛选 `SEARCH_FILTER_APPLIED` 事件
-`filter_type: "sort"` 筛选
-`filter_value` 分组统计
### 3. 新闻热度分析
**问题**: 哪些新闻最受欢迎?
**方法**:
- 筛选 `NEWS_ARTICLE_CLICKED` 事件
-`event_id` 分组
- 统计点击次数
### 4. 用户旅程分析
**问题**: 用户从搜索到点击的转化率?
**方法**:
- 创建漏斗:
1. `COMMUNITY_PAGE_VIEWED`
2. `SEARCH_QUERY_SUBMITTED`
3. `NEWS_ARTICLE_CLICKED`
- 分析每一步的流失率
---
## 🔧 扩展计划
### 下一步:其他页面追踪
按优先级排序:
1. **Concept概念中心** ⭐⭐⭐
- 搜索概念
- 点击概念卡片
- 查看概念详情
- 点击概念内股票
2. **StockOverview个股中心** ⭐⭐⭐
- 搜索股票
- 点击股票卡片
- 查看股票详情
- 切换 Tab
3. **LimitAnalyse涨停分析** ⭐⭐
- 进入页面
- 点击涨停板块
- 展开板块详情
- 点击涨停个股
4. **TradingSimulation模拟盘** ⭐⭐
- 进入模拟盘
- 下单操作
- 查看持仓
- 查看历史
5. **Company公司详情**
- 查看公司概览
- 查看财务全景
- 查看盈利预测
- Tab 切换
---
## 💡 最佳实践
### 1. 属性命名规范
- 使用 **snake_case** 命名(与 PostHog 推荐一致)
- 属性名要 **描述性强**,易于理解
- 使用 **布尔值** 表示是/否has_xxx, is_xxx
- 使用 **枚举值** 表示类别filter_type: "sort"
### 2. 事件追踪原则
- **追踪用户意图**,而不仅仅是点击
- **添加上下文**帮助分析previous_value, source
- **保持一致性**,相似事件使用相似属性
- **避免敏感信息**,不追踪用户隐私数据
### 3. 性能优化
- 使用 **`usePostHogTrack`** 而不是 `usePostHogRedux`
- 更轻量,只订阅追踪功能
- 避免不必要的重渲染
-**Custom Hooks** 中集成,而不是每个组件
- 集中管理,易于维护
- 减少重复代码
---
## ⚠️ 注意事项
### 1. 依赖管理
确保 `useCallback` 的依赖数组包含 `track`
```javascript
// ✅ 正确
const handleClick = useCallback(() => {
track(EVENT_NAME, { ... });
}, [track]);
// ❌ 错误(缺少 track
const handleClick = useCallback(() => {
track(EVENT_NAME, { ... });
}, []);
```
### 2. 事件去重
避免重复追踪相同事件:
```javascript
// ✅ 正确(只在值变化时追踪)
if (newFilters.sort !== filters.sort) {
track(SEARCH_FILTER_APPLIED, { ... });
}
// ❌ 错误(每次都追踪)
track(SEARCH_FILTER_APPLIED, { ... });
```
### 3. 空值处理
使用安全的属性访问:
```javascript
// ✅ 正确
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0)
// ❌ 错误(可能报错)
has_stocks: event.related_stocks.length > 0
```
---
## 📚 参考资料
- **PostHog Events 文档**: https://posthog.com/docs/data/events
- **PostHog Properties 文档**: https://posthog.com/docs/data/properties
- **Redux PostHog 集成**: `POSTHOG_REDUX_INTEGRATION.md`
- **事件常量定义**: `src/lib/constants.js`
---
## 🎉 总结
### 已实现的功能
- ✅ Home 页面追踪2个事件
- ✅ StockOverview 页面完整追踪10个事件✨ 已完成
- ✅ Concept 页面完整追踪9个事件
- ✅ Company 页面完整追踪5个事件
- ✅ Community 页面完整追踪7个事件
- ✅ Custom Hook 集成方案
- ✅ Redux DevTools 调试支持
- ✅ 详细的事件属性
### 追踪的用户行为
**Home 页面**:
1. **页面访问** - 了解流量来源、登录转化率
2. **功能卡片点击** - 识别最受欢迎的功能
3. **推荐功能效果** - 分析特色功能(新闻中心)的点击率
**StockOverview 页面** ✨:
1. **页面访问** - 了解个股中心流量
2. **搜索行为** - 股票搜索、搜索结果点击
3. **概念交互** - 热门概念点击、概念股票标签点击
4. **热力图交互** - 热力图中股票点击
5. **数据筛选** - 日期选择变化
6. **市场统计** - 市场数据查看
**Concept 页面**:
1. **页面访问** - 了解概念中心流量
2. **搜索行为** - 概念搜索、搜索结果数量
3. **筛选偏好** - 排序方式、日期选择
4. **概念交互** - 概念点击、位置追踪
5. **个股查看** - 查看个股、股票点击
6. **时间轴查看** - 历史时间轴
7. **翻页行为** - 优化分页逻辑
8. **视图切换** - 网格/列表偏好
**Company 页面**:
1. **页面访问** - 了解公司详情页流量
2. **股票搜索** - 用户查询哪些股票
3. **Tab 切换** - 用户最关注哪个 Tab概览/行情/财务/预测)
4. **自选股管理** - 自选股添加/移除行为
5. **股票切换** - 分析用户查看股票的路径
**Community 页面**:
1. **页面访问** - 了解流量来源
2. **搜索行为** - 了解用户需求
3. **筛选偏好** - 优化默认设置
4. **内容点击** - 识别热门内容
5. **详情查看** - 分析用户兴趣
6. **翻页行为** - 优化分页逻辑
### 下一步计划
1. ~~在关键页面实施追踪Home, StockOverview, Concept, Company, Community~~ ✅ 已完成
2. **下一步**:其他页面追踪
- LimitAnalyse涨停分析⭐⭐
- TradingSimulation模拟盘⭐⭐
3. 创建 PostHog Dashboard 和 Insights
4. 设置用户行为漏斗分析
5. 配置 Feature Flags 进行 A/B 测试
---
**Home, StockOverview, Concept, Company, Community 页面追踪全部完成!** 🚀
现在你可以在 PostHog 后台看到完整的用户行为数据:
- **首页** → **个股中心/概念中心/公司详情/新闻中心** 的完整转化路径
- **搜索行为**、**筛选偏好**、**内容点击** 的详细数据
- **Tab 切换**、**视图切换**、**翻页行为** 的用户习惯分析
- **自选股管理** 的用户行为追踪
共追踪 **33个事件**,覆盖 **5个核心页面**

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": {
"@ant-design/icons": "^6.0.0",
"@asseinfo/react-kanban": "^2.2.0",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@chakra-ui/theme-tools": "^1.3.6",
"@chakra-ui/icons": "^2.2.6",
"@chakra-ui/react": "^2.10.9",
"@chakra-ui/theme-tools": "^2.2.6",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
@@ -29,6 +29,7 @@
"classnames": "^2.5.1",
"d3": "^7.9.0",
"date-fns": "^2.23.0",
"dayjs": "^1.11.19",
"draft-js": "^0.11.7",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
@@ -39,9 +40,8 @@
"history": "^5.3.0",
"lucide-react": "^0.540.0",
"match-sorter": "6.3.0",
"moment": "^2.29.1",
"nouislider": "15.0.0",
"posthog-js": "^1.281.0",
"posthog-js": "^1.295.0",
"react": "18.3.1",
"react-apexcharts": "^1.3.9",
"react-big-calendar": "^0.33.2",
@@ -78,7 +78,8 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"tsparticles-slim": "^2.12.0"
"tsparticles-slim": "^2.12.0",
"typescript": "^5.9.3"
},
"resolutions": {
"react-error-overlay": "6.0.9",
@@ -138,7 +139,6 @@
"react-error-overlay": "6.0.9",
"sharp": "^0.34.4",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0"
},

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" dir="ltr" layout="admin">
<html lang="zh-CN" dir="ltr" layout="admin">
<head>
<meta charset="utf-8" />
<meta
@@ -7,6 +7,177 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!-- 基本 SEO -->
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
<meta name="description" content="基于金融大语言模型实时监控股市行情、a股、美股提供英伟达、小米等企业舆情分析助力投资决策" />
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
<link rel="canonical" href="https://valuefrontier.cn/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://valuefrontier.cn/" />
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
<meta property="og:description" content="基于金融大语言模型实时监控股市行情、a股、美股提供英伟达、小米等企业舆情分析" />
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
<meta property="og:site_name" content="价值前沿" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://valuefrontier.cn/" />
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
<meta name="twitter:description" content="基于金融大语言模型实时监控股市行情、a股、美股" />
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
<!-- SEO 增强 -->
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="author" content="价值前沿团队" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
<!-- 性能优化: DNS 预连接 -->
<link rel="preconnect" href="https://valuefrontier.cn" />
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
<!-- JSON-LD 结构化数据: 组织信息 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "价值前沿",
"url": "https://valuefrontier.cn",
"logo": "https://valuefrontier.cn/logo.png",
"description": "基于金融大语言模型的智能舆情分析平台",
"foundingDate": "2023",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "Customer Service",
"availableLanguage": ["zh-CN"]
},
"sameAs": [
"https://valuefrontier.cn"
]
}
</script>
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "价值前沿",
"url": "https://valuefrontier.cn",
"description": "金融AI舆情分析系统实时监控股市行情",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}
</script>
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "价值前沿",
"applicationCategory": "FinanceApplication",
"operatingSystem": "Web",
"url": "https://valuefrontier.cn",
"description": "基于金融大语言模型实时监控股市行情、a股、美股提供企业舆情分析",
"offers": [
{
"@type": "Offer",
"name": "专业版",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "198",
"priceCurrency": "CNY",
"billingDuration": "P1M",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": "1",
"unitText": "月"
}
},
"availability": "https://schema.org/InStock",
"url": "https://valuefrontier.cn/home/pages/account/subscription"
},
{
"@type": "Offer",
"name": "旗舰版",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "998",
"priceCurrency": "CNY",
"billingDuration": "P1M",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": "1",
"unitText": "月"
}
},
"availability": "https://schema.org/InStock",
"url": "https://valuefrontier.cn/home/pages/account/subscription"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "1250",
"bestRating": "5",
"worstRating": "1"
},
"featureList": [
"实时舆情监控",
"智能事件分析",
"多维度数据可视化",
"AI驱动的投资建议",
"行业板块分析"
]
}
</script>
<!-- JSON-LD 结构化数据: 面包屑导航 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "首页",
"item": "https://valuefrontier.cn/"
},
{
"@type": "ListItem",
"position": 2,
"name": "事件中心",
"item": "https://valuefrontier.cn/community"
},
{
"@type": "ListItem",
"position": 3,
"name": "概念分析",
"item": "https://valuefrontier.cn/concepts"
},
{
"@type": "ListItem",
"position": 4,
"name": "个股分析",
"item": "https://valuefrontier.cn/stocks"
}
]
}
</script>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link
@@ -15,10 +186,19 @@
href="%PUBLIC_URL%/apple-icon.png"
/>
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<title>价值前沿——LLM赋能的分析平台</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<noscript>
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
<div>
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
请在浏览器设置中启用 JavaScript然后刷新页面。
</p>
</div>
</div>
</noscript>
<div id="root"></div>
</body>
</html>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

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.
*/
import React, { useEffect } from "react";
import React, { useEffect, useRef } from "react";
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
// Routes
import AppRoutes from './routes';
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
// Utils
import { logger } from './utils/logger';
// PostHog 追踪
import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts
import { useAuth } from '@contexts/AuthContext';
/**
* AppContent - 应用核心内容
* 负责 PostHog 初始化和渲染路由
*/
function AppContent() {
const dispatch = useDispatch();
const location = useLocation();
const { isAuthenticated } = useAuth();
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化
useEffect(() => {
@@ -43,6 +56,67 @@ function AppContent() {
logger.info('App', 'PostHog Redux 初始化已触发');
}, [dispatch]);
// ✅ 首次访问追踪
useEffect(() => {
const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) {
const urlParams = new URLSearchParams(location.search);
// ⚡ 使用异步追踪,不阻塞页面渲染
trackEventAsync('first_visit', {
referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname,
timestamp: new Date().toISOString()
});
localStorage.setItem('has_visited', 'true');
}
}, [location.search, location.pathname]);
// ✅ 页面浏览时长追踪
useEffect(() => {
// 计算上一个页面的停留时长
const calculateAndTrackDuration = () => {
const exitTime = Date.now();
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
if (duration > 1) {
// ⚡ 使用异步追踪,不阻塞页面切换
trackEventAsync('page_view_duration', {
path: currentPathRef.current,
duration_seconds: duration,
is_authenticated: isAuthenticated,
timestamp: new Date().toISOString()
});
}
};
// 路由切换时追踪上一个页面的时长
if (currentPathRef.current !== location.pathname) {
calculateAndTrackDuration();
// 更新为新页面
currentPathRef.current = location.pathname;
pageEnterTimeRef.current = Date.now();
}
// 页面关闭/刷新时追踪时长
const handleBeforeUnload = () => {
calculateAndTrackDuration();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [location.pathname, isAuthenticated]);
return <AppRoutes />;
}

View File

@@ -1,237 +0,0 @@
/**
* vf_react App.jsx集成示例
*
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
*
* 集成步骤:
* 1. 将bytedesk-integration文件夹复制到src/目录
* 2. 在App.jsx中导入BytedeskWidget和配置
* 3. 添加BytedeskWidget组件代码如下
* 4. 配置.env文件参考.env.bytedesk.example
*/
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; // 如果使用react-router
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
// ============================================================================
// 方案一: 全局集成(推荐)
// 适用场景: 客服系统需要在所有页面显示
// ============================================================================
function App() {
// ========== vf_react原有代码保持不变 ==========
// 这里是您原有的App.jsx代码
// 例如: const [user, setUser] = useState(null);
// 例如: const [theme, setTheme] = useState('light');
// ... 保持原有逻辑不变 ...
// ========== Bytedesk集成代码开始 ==========
const location = useLocation(); // 获取当前路径
const [showBytedesk, setShowBytedesk] = useState(false);
// 根据页面路径决定是否显示客服
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 获取Bytedesk配置
const bytedeskConfig = getBytedeskConfig();
// 客服加载成功回调
const handleBytedeskLoad = (bytedesk) => {
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
};
// 客服加载失败回调
const handleBytedeskError = (error) => {
console.error('[App] Bytedesk客服系统加载失败', error);
};
// ========== Bytedesk集成代码结束 ==========
return (
<div className="App">
{/* ========== vf_react原有内容保持不变 ========== */}
{/* 这里是您原有的App.jsx JSX代码 */}
{/* 例如: <Header /> */}
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
{/* ... 保持原有结构不变 ... */}
{/* ========== Bytedesk客服Widget ========== */}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleBytedeskLoad}
onError={handleBytedeskError}
/>
)}
</div>
);
}
export default App;
// ============================================================================
// 方案二: 带用户信息集成
// 适用场景: 需要将登录用户信息传递给客服端
// ============================================================================
/*
import React, { useState, useEffect, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
function App() {
// 获取登录用户信息
const { user } = useContext(AuthContext);
const location = useLocation();
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 根据用户信息生成配置
const bytedeskConfig = user
? getBytedeskConfigWithUser(user)
: getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案三: 条件性加载
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
// ============================================================================
/*
import React, { useState, useEffect } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [user, setUser] = useState(null);
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
// 只有在用户登录且为普通用户时显示客服
if (user && user.role === 'customer') {
setShowBytedesk(true);
} else {
setShowBytedesk(false);
}
}, [user]);
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案四: 动态控制显示/隐藏
// 适用场景: 需要通过按钮或其他交互控制客服显示
// ============================================================================
/*
import React, { useState } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
const bytedeskConfig = getBytedeskConfig();
const toggleBytedesk = () => {
setShowBytedesk(prev => !prev);
};
return (
<div className="App">
// ... 您的原有代码 ...
{/* 自定义客服按钮 *\/}
<button onClick={toggleBytedesk} className="custom-service-button">
{showBytedesk ? '关闭客服' : '联系客服'}
</button>
{/* 客服Widget *\/}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 重要提示
// ============================================================================
/**
* 1. CSS样式兼容性
* - Bytedesk Widget使用Shadow DOM不会影响您的全局样式
* - Widget的样式可通过config中的theme配置调整
*
* 2. 性能优化
* - Widget脚本采用异步加载不会阻塞页面渲染
* - 建议在非关键页面(如登录、支付页)隐藏客服
*
* 3. 错误处理
* - 如果客服脚本加载失败,不会影响主应用
* - 建议添加onError回调进行错误监控
*
* 4. 调试模式
* - 查看浏览器控制台的[Bytedesk]前缀日志
* - 检查Network面板确认脚本加载成功
*
* 5. 生产部署
* - 确保.env文件配置正确特别是REACT_APP_BYTEDESK_API_URL
* - 确保CORS已在后端配置允许您的前端域名
* - 在管理后台配置正确的工作组IDsid
*/

View File

@@ -1,27 +1,10 @@
/**
* Bytedesk客服配置文件
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_uid
*
* 架构说明:
* - iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
* - 本地CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
* - baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
*/
// 从环境变量读取配置
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
/**
* Bytedesk客服基础配置
- iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
- 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
- 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
- baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*/
export const bytedeskConfig = {
// API服务地址如果 SDK 需要调用 API
@@ -61,9 +44,9 @@ export const bytedeskConfig = {
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
org: df_org_uid, // 组织ID
t: '1', // 类型: 1=人工客服, 2=机器人
sid: BYTEDESK_SID, // 工作组ID
sid: df_wg_uid, // 工作组ID
},
};
@@ -111,45 +94,8 @@ export const getBytedeskConfigWithUser = (user) => {
return config;
};
/**
* 根据页面路径判断是否显示客服
*
* @param {string} pathname - 当前页面路径
* @returns {boolean} 是否显示客服
*/
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
];
// 检查是否在黑名单
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
// 默认所有页面都显示客服
return true;
/* ============================================
白名单模式(备用,需要时取消注释)
============================================
const allowedPages = [
'/', // 首页
'/home', // 主页
'/products', // 产品页
'/pricing', // 价格页
'/contact', // 联系我们
];
// 只在白名单页面显示客服
return allowedPages.some(page => pathname.startsWith(page));
============================================ */
};
export default {
bytedeskConfig,
getBytedeskConfig,
getBytedeskConfigWithUser,
shouldShowCustomerService,
};

View File

@@ -356,24 +356,22 @@ export default function AuthFormContent() {
// 更新session
await checkSession();
// ✅ 兼容后端两种命名格式camelCase (isNewUser) 和 snake_case (is_new_user)
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
// 追踪登录成功并识别用户
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
// ✅ 保留登录成功 toast关键操作提示
toast({
title: data.isNewUser ? '注册成功' : '登录成功',
title: isNewUser ? '注册成功' : '登录成功',
description: config.successDescription,
status: "success",
duration: 2000,
});
logger.info('AuthFormContent', '登录成功', {
isNewUser: data.isNewUser,
userId: data.user?.id
});
// 检查是否为新注册用户
if (data.isNewUser) {
if (isNewUser) {
// 新注册用户,延迟后显示昵称设置引导
setTimeout(() => {
setCurrentPhone(phone);

View File

@@ -1,5 +1,5 @@
// src/components/Auth/AuthModalManager.js
import React from 'react';
import React, { useEffect, useRef } from 'react';
import {
Modal,
ModalOverlay,
@@ -10,6 +10,8 @@ import {
} from '@chakra-ui/react';
import { useAuthModal } from '../../hooks/useAuthModal';
import AuthFormContent from './AuthFormContent';
import { trackEventAsync } from '@lib/posthog';
import { ACTIVATION_EVENTS } from '@lib/constants';
/**
* 全局认证弹窗管理器
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
closeModal
} = useAuthModal();
// ✅ 追踪弹窗打开次数(用于漏斗分析)
const hasTrackedOpen = useRef(false);
useEffect(() => {
if (isAuthModalOpen && !hasTrackedOpen.current) {
// ✅ 使用异步追踪,不阻塞渲染
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
modal_type: 'auth_modal',
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
});
hasTrackedOpen.current = true;
}
// ✅ 弹窗关闭时重置标记(允许再次追踪)
if (!isAuthModalOpen) {
hasTrackedOpen.current = false;
}
}, [isAuthModalOpen]);
// 响应式尺寸配置
const modalSize = useBreakpointValue({
base: "md", // 移动端md不占满全屏

View File

@@ -0,0 +1,53 @@
import React from "react";
import Link, { LinkProps } from "next/link";
type CommonProps = {
className?: string;
children?: React.ReactNode;
isPrimary?: boolean;
isSecondary?: boolean;
};
type ButtonAsButton = {
as?: "button";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonAsAnchor = {
as: "a";
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
type ButtonAsLink = {
as: "link";
} & LinkProps;
type ButtonProps = CommonProps &
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
const Button: React.FC<ButtonProps> = ({
className,
children,
isPrimary,
isSecondary,
as = "button",
...props
}) => {
const isLink = as === "link";
const Component: React.ElementType = isLink ? Link : as;
return (
<Component
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
} ${
isSecondary
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
: ""
} ${className || ""}`}
{...(isLink ? (props as LinkProps) : props)}
>
{children}
</Component>
);
};
export default Button;

View File

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

View File

@@ -9,13 +9,12 @@ import { logger } from '../utils/logger';
// Global Components
import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer';
import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
@@ -71,12 +70,10 @@ function ConnectionStatusBarWrapper() {
* - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/
export function GlobalComponents() {
const location = useLocation();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<>
@@ -92,16 +89,11 @@ export function GlobalComponents() {
{/* 通知容器 */}
<NotificationContainer />
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
)}
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
</>
);
}

View File

@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
</HStack>
</Link>
<Link
onClick={() => handleNavigate('/value-forum')}
py={1}
px={3}
borderRadius="md"
_hover={{}}
cursor="not-allowed"
color="gray.400"
pointerEvents="none"
_hover={{ bg: 'gray.50' }}
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
>
<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
py={1}

View File

@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"
color="gray.400"
onClick={() => {
navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
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
isDisabled

View File

@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</HStack>
</Flex>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
<MenuItem
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 isDisabled cursor="not-allowed" color="gray.400">
<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 ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import moment from 'moment';
import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent';
import { logger } from '../../utils/logger';
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = moment(eventTime);
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) {
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = moment(eventTime);
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) {
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
// 计算事件标记线位置
let markLineData = [];
if (eventTime && times.length > 0) {
const eventMoment = moment(eventTime);
const eventMoment = dayjs(eventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
if (activeChartType === 'timeline') {

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

View File

@@ -0,0 +1,2 @@
// Type declarations for SubscriptionContentNew component
export {};

File diff suppressed because it is too large Load Diff

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 { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { SPECIAL_EVENTS } from '@lib/constants';
// 创建认证上下文
const AuthContext = createContext();
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
if (prevUser && prevUser.id === data.user.id) {
return prevUser;
}
// ✅ 识别用户身份到 PostHog
identifyUser(data.user.id, {
email: data.user.email,
username: data.user.username,
subscription_tier: data.user.subscription_tier,
role: data.user.role,
registration_date: data.user.created_at
});
return data.user;
});
setIsAuthenticated((prev) => prev === true ? prev : true);
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user);
setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Logged In' 或 'User Signed Up'
// 属性名login_method (不是 loginType)
// ⚡ 移除toast让调用者处理UI反馈避免并发更新冲突
// toast({
// title: "登录成功",
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user);
setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Signed Up'(不是 'user_registered'
// 属性名login_method不是 method
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
}
};
// 邮箱注册
const registerWithEmail = async (email, code, username, password) => {
try {
setIsLoading(true);
const response = await fetch(`/api/auth/register/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
email,
code,
username,
password
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
logger.error('AuthContext', 'registerWithEmail', error);
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 发送手机验证码
const sendSmsCode = async (phone) => {
try {
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
}
};
// 发送邮箱验证码
const sendEmailCode = async (email) => {
try {
const response = await fetch(`/api/auth/send-email-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '发送失败');
}
// ❌ 移除成功 toast
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
return { success: true };
} catch (error) {
// ❌ 移除错误 toast
logger.error('AuthContext', 'sendEmailCode', error);
return { success: false, error: error.message };
}
};
// 登出方法
const logout = async () => {
try {
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
credentials: 'include'
});
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
timestamp: new Date().toISOString(),
user_id: user?.id || null,
session_duration_minutes: user?.session_start
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
: null,
});
// ✅ 重置 PostHog 用户会话
resetUser();
// 清除本地状态
setUser(null);
setIsAuthenticated(false);
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
updateUser,
login,
registerWithPhone,
registerWithEmail,
sendSmsCode,
sendEmailCode,
logout,
hasRole,
refreshSession,

View File

@@ -353,13 +353,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
console.log('[NotificationContext] 通知数据:', notificationData);
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
logger.debug('NotificationContext', 'sendBrowserNotification 被调用', {
notificationData,
browserPermission
});
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知');
return;
}
@@ -371,7 +371,7 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
logger.debug('NotificationContext', '准备发送浏览器通知', {
title,
body: content,
tag,
@@ -390,12 +390,12 @@ export const NotificationProvider = ({ children }) => {
});
if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
logger.info('NotificationContext', '通知对象创建成功', { notification });
// 设置点击处理(聚焦窗口并跳转)
if (link) {
notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link);
logger.info('NotificationContext', '通知被点击,跳转到', { link });
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
@@ -405,7 +405,7 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else {
console.error('[NotificationContext] ❌ 通知对象创建失败!');
logger.error('NotificationContext', '通知对象创建失败');
}
}, [browserPermission]);
@@ -640,19 +640,18 @@ export const NotificationProvider = ({ children }) => {
*/
useEffect(() => {
addNotificationRef.current = addNotification;
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
logger.debug('NotificationContext', '已更新 addNotificationRef');
}, [addNotification]);
useEffect(() => {
adaptEventToNotificationRef.current = adaptEventToNotification;
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef');
}, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接方案2只注册一次', 'color: #673AB7; font-weight: bold;');
logger.info('NotificationContext', '初始化 Socket 连接方案2只注册一次');
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
@@ -661,15 +660,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是首次连接还是重连
if (isFirstConnect.current) {
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
logger.info('NotificationContext', '首次连接成功', {
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
logger.info('NotificationContext', 'Socket connected (first time)');
} else {
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
logger.info('NotificationContext', 'Socket reconnected');
// 清除之前的定时器
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) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] 订阅确认:', data);
logger.info('NotificationContext', 'Events subscribed', data);
logger.info('NotificationContext', '订阅成功', data);
},
});
} else {
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
}
});
@@ -705,8 +701,7 @@ export const NotificationProvider = ({ children }) => {
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason });
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
@@ -716,15 +711,13 @@ export const NotificationProvider = ({ children }) => {
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed');
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
console.error('[NotificationContext] ❌ 重连失败');
toast({
title: '连接失败',
@@ -737,21 +730,17 @@ export const NotificationProvider = ({ children }) => {
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => {
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('[NotificationContext] 原始事件数据:', data);
console.log('[NotificationContext] 事件 ID:', data?.id);
console.log('[NotificationContext] 事件标题:', data?.title);
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
console.log('[NotificationContext] 事件重要性:', data?.importance);
logger.info('NotificationContext', 'Received new event', data);
logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
logger.error('NotificationContext', 'Refs not initialized', {
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
@@ -770,14 +759,12 @@ export const NotificationProvider = ({ children }) => {
}
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
return;
}
processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
@@ -790,45 +777,41 @@ export const NotificationProvider = ({ children }) => {
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
console.log('[NotificationContext] 正在转换事件格式...');
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
console.log('[NotificationContext] 转换后的通知对象:', notification);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
console.log('[NotificationContext] 准备添加通知到队列...');
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', 'Event update callback error', error);
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
logger.error('NotificationContext', '事件更新回调执行失败', error);
}
});
console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
logger.debug('NotificationContext', '所有事件更新回调已触发');
}
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
// ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
console.log('[NotificationContext] 📢 收到系统通知:', data);
logger.info('NotificationContext', '收到系统通知', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} 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;
@@ -836,13 +819,12 @@ export const NotificationProvider = ({ children }) => {
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();
// ========== 清理函数(组件卸载时) ==========
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
logger.info('NotificationContext', '清理 Socket 连接');
// 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) {
@@ -868,7 +850,7 @@ export const NotificationProvider = ({ children }) => {
// 断开连接
socket.disconnect();
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
logger.info('NotificationContext', '清理完成');
};
}, []); // ⚠️ 空依赖数组,确保只执行一次
@@ -984,92 +966,6 @@ export const NotificationProvider = ({ children }) => {
};
}, [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 = {
notifications,
isConnected,

View File

@@ -10,20 +10,17 @@
* 全局 API:
* - window.__DEBUG__ - 调试 API 主对象
* - window.__DEBUG__.api - API 调试工具
* - window.__DEBUG__.notification - 通知调试工具
* - window.__DEBUG__.socket - Socket 调试工具
* - window.__DEBUG__.help() - 显示帮助信息
* - window.__DEBUG__.exportAll() - 导出所有日志
*/
import { apiDebugger } from './apiDebugger';
import { notificationDebugger } from './notificationDebugger';
import { socketDebugger } from './socketDebugger';
class DebugToolkit {
constructor() {
this.api = apiDebugger;
this.notification = notificationDebugger;
this.socket = socketDebugger;
}
@@ -47,7 +44,6 @@ class DebugToolkit {
// 初始化各个调试工具
this.api.init();
this.notification.init();
this.socket.init();
// 暴露到全局
@@ -69,22 +65,13 @@ class DebugToolkit {
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
console.log('');
console.log('%c2通知调试:', 'color: #9C27B0; 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('%c2Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
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__.exportAll() - 导出所有日志');
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
@@ -113,7 +100,6 @@ class DebugToolkit {
const allLogs = {
timestamp: new Date().toISOString(),
api: this.api.getLogs(),
notification: this.notification.getLogs(),
socket: this.socket.getLogs(),
};
@@ -138,15 +124,11 @@ class DebugToolkit {
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
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;');
const socketStats = this.socket.printStats();
return {
api: apiStats,
notification: notificationStats,
socket: socketStats,
};
}
@@ -157,7 +139,6 @@ class DebugToolkit {
clearAll() {
console.log('[Debug Toolkit] Clearing all logs...');
this.api.clearLogs();
this.notification.clearLogs();
this.socket.clearLogs();
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;');
// 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();
// 2. 通知权限
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
const notificationStatus = this.notification.checkPermission();
// 3. API 错误
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
// 2. API 错误
console.log('\n%c[2/2] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
const recentErrors = this.api.getRecentErrors(5);
if (recentErrors.length > 0) {
console.table(
@@ -193,11 +170,10 @@ class DebugToolkit {
console.log('✅ 没有 API 错误');
}
// 4. 汇总报告
// 3. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: socketStatus,
notification: notificationStatus,
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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoLoad]);
}, [autoLoad, loadFunction]);
return {
data,

View File

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

View File

@@ -10,6 +10,11 @@ let isInitialized = false;
* Should be called once when the app starts
*/
export const initPostHog = () => {
// 开发环境禁用 PostHog减少日志噪音仅生产环境启用
if (process.env.NODE_ENV === 'development') {
return;
}
// 防止重复初始化
if (isInitializing || isInitialized) {
console.log('📊 PostHog 已初始化或正在初始化中,跳过重复调用');
@@ -33,79 +38,68 @@ export const initPostHog = () => {
posthog.init(apiKey, {
api_host: apiHost,
// Pageview tracking - manual control for better accuracy
capture_pageview: false, // We'll manually capture with custom properties
capture_pageleave: true, // Auto-capture when user leaves page
// 📄 页面浏览追踪
capture_pageview: true, // 自动捕获页面浏览事件
capture_pageleave: true, // 自动捕获用户离开页面事件
// Session Recording Configuration
// 📹 会话录制配置(Session Recording
session_recording: {
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
// Privacy: Mask sensitive input fields
// 🔒 隐私保护:遮蔽敏感输入字段(录制时会自动打码)
maskInputOptions: {
password: true,
email: true,
phone: true,
'data-sensitive': true, // Custom attribute for sensitive fields
password: true, // 遮蔽密码输入框
email: true, // 遮蔽邮箱输入框
phone: true, // 遮蔽手机号输入框
'data-sensitive': true, // 遮蔽带有 data-sensitive 属性的字段(可在 HTML 中自定义)
},
// Record canvas for charts/graphs
// 📊 录制 Canvas 画布内容(用于记录图表、图形等可视化内容)
recordCanvas: true,
// Network payload capture (useful for debugging API issues)
// 🌐 网络请求数据捕获(用于调试 API 问题)
networkPayloadCapture: {
recordHeaders: true,
recordBody: true,
// Don't record sensitive endpoints
recordHeaders: true, // 捕获请求头
recordBody: true, // 捕获请求体
// 🚫 敏感接口黑名单(不记录以下接口的数据)
urlBlocklist: [
'/api/auth/session',
'/api/auth/login',
'/api/auth/register',
'/api/payment',
'/api/auth/session', // 会话接口
'/api/auth/login', // 登录接口
'/api/auth/register', // 注册接口
'/api/payment', // 支付接口
],
},
},
// Performance optimization
batch_size: 10, // Send events in batches of 10
batch_interval_ms: 3000, // Or every 3 seconds
// ⚡ 性能优化:批量发送事件
batch_size: 10, // 每 10 个事件发送一次
batch_interval_ms: 3000, // 或每 3 秒发送一次(两个条件满足其一即发送)
// Privacy settings
respect_dnt: true, // Respect Do Not Track browser setting
persistence: 'localStorage+cookie', // Use both for reliability
// 🔐 隐私设置
respect_dnt: true, // 尊重浏览器的"禁止追踪"Do Not Track设置
persistence: 'localStorage+cookie', // 同时使用 localStorage 和 Cookie 存储(提高可靠性)
// Feature flags (for A/B testing)
// 🚩 功能开关(Feature Flags- 用于 A/B 测试和灰度发布
bootstrap: {
featureFlags: {},
featureFlags: {}, // 初始功能开关配置(可从服务端动态加载)
},
// Autocapture settings
// 🖱️ 自动捕获设置(Autocapture
autocapture: {
// Automatically capture clicks on buttons, links, etc.
// 自动捕获用户交互事件(点击、提交、修改等)
dom_event_allowlist: ['click', 'submit', 'change'],
// Capture additional element properties
capture_copied_text: false, // Don't capture copied text (privacy)
},
// Development debugging
loaded: (posthogInstance) => {
if (process.env.NODE_ENV === 'development') {
console.log('✅ PostHog initialized successfully');
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
}
// 捕获额外的元素属性
capture_copied_text: false, // 不捕获用户复制的文本(隐私保护)
},
});
isInitialized = true;
console.log('📊 PostHog Analytics initialized');
} catch (error) {
// 忽略 AbortError通常由热重载或快速导航引起
if (error.name === 'AbortError') {
console.log('⚠️ PostHog 初始化请求被中断(可能是热重载),这是正常的');
return;
}
console.error('❌ PostHog initialization failed:', error);
} finally {
isInitializing = false;
}
@@ -142,8 +136,6 @@ export const identifyUser = (userId, userProperties = {}) => {
last_login: new Date().toISOString(),
...userProperties,
});
// console.log('👤 User identified:', userId); // 已关闭:减少日志
} catch (error) {
console.error('❌ User identification failed:', error);
}
@@ -158,7 +150,6 @@ export const identifyUser = (userId, userProperties = {}) => {
export const setUserProperties = (properties) => {
try {
posthog.people.set(properties);
// console.log('📝 User properties updated'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Failed to update user properties:', error);
}
@@ -176,15 +167,35 @@ export const trackEvent = (eventName, properties = {}) => {
...properties,
timestamp: new Date().toISOString(),
});
// if (process.env.NODE_ENV === 'development') {
// console.log('📍 Event tracked:', eventName, properties);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Event tracking failed:', error);
}
};
/**
* 异步追踪事件(不阻塞主线程)
* 使用 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
*
@@ -201,9 +212,6 @@ export const trackPageView = (pagePath, properties = {}) => {
...properties,
});
// if (process.env.NODE_ENV === 'development') {
// console.log('📄 Page view tracked:', pagePath);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Page view tracking failed:', error);
}
@@ -216,7 +224,6 @@ export const trackPageView = (pagePath, properties = {}) => {
export const resetUser = () => {
try {
posthog.reset();
// console.log('🔄 User session reset'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Session reset failed:', error);
}
@@ -228,7 +235,6 @@ export const resetUser = () => {
export const optOut = () => {
try {
posthog.opt_out_capturing();
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-out failed:', error);
}
@@ -240,7 +246,6 @@ export const optOut = () => {
export const optIn = () => {
try {
posthog.opt_in_capturing();
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-in failed:', error);
}

View File

@@ -102,6 +102,17 @@ export const homeRoutes = [
}
},
// 数据浏览器 - /home/data-browser
{
path: 'data-browser',
component: lazyComponents.DataBrowser,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '数据浏览器',
description: '化工商品数据分类树浏览器'
}
},
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',

View File

@@ -38,6 +38,13 @@ export const lazyComponents = {
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
// 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
};
/**
@@ -63,4 +70,7 @@ export const {
FinancialPanorama,
MarketDataView,
AgentChat,
ValueForum,
ForumPostDetail,
DataBrowser,
} = 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模块 ====================
{
path: 'agent-chat',

View File

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

View File

@@ -0,0 +1,280 @@
/**
* 商品分类树数据服务
* 对接化工商品数据分类树API
* API文档: category_tree_openapi.json
*/
import { getApiBase } from '@utils/apiConfig';
// 类型定义
export interface TreeMetric {
metric_id: string;
metric_name: string;
source: 'SMM' | 'Mysteel';
frequency: string;
unit: string;
description?: string;
}
export interface TreeNode {
name: string;
path: string;
level: number;
children?: TreeNode[];
metrics?: TreeMetric[];
}
export interface CategoryTreeResponse {
source: 'SMM' | 'Mysteel';
total_metrics: number;
tree: TreeNode[];
}
export interface ErrorResponse {
detail: string;
}
export interface MetricDataPoint {
date: string;
value: number | null;
}
export interface MetricDataResponse {
metric_id: string;
metric_name: string;
source: string;
frequency: string;
unit: string;
data: MetricDataPoint[];
total_count: number;
}
/**
* 获取分类树(支持深度控制)
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @param maxDepth 返回的最大层级深度默认1层推荐懒加载
* @returns 分类树数据
*/
export const fetchCategoryTree = async (
source: 'SMM' | 'Mysteel',
maxDepth: number = 1
): Promise<CategoryTreeResponse> => {
try {
const response = await fetch(
`/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: CategoryTreeResponse = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryTree error:', error);
throw error;
}
};
/**
* 获取特定节点及其子树
* @param path 节点完整路径(用 | 分隔)
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @returns 节点数据及其子树
*/
export const fetchCategoryNode = async (
path: string,
source: 'SMM' | 'Mysteel'
): Promise<TreeNode> => {
try {
const encodedPath = encodeURIComponent(path);
const response = await fetch(
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: TreeNode = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryNode error:', error);
throw error;
}
};
export interface MetricSearchResult {
source: string;
metric_id: string;
metric_name: string;
unit: string;
frequency: string;
category_path: string;
description?: string;
score?: number;
}
export interface SearchResponse {
total: number;
results: MetricSearchResult[];
query: string;
}
/**
* 搜索指标
* @param keywords 搜索关键词(支持空格分隔多个词)
* @param source 数据源过滤(可选)
* @param frequency 频率过滤(可选)
* @param size 返回结果数量默认100
* @returns 搜索结果
*/
export const searchMetrics = async (
keywords: string,
source?: 'SMM' | 'Mysteel',
frequency?: string,
size: number = 100
): Promise<SearchResponse> => {
try {
const params = new URLSearchParams({
keywords,
size: size.toString(),
});
if (source) params.append('source', source);
if (frequency) params.append('frequency', frequency);
const response = await fetch(`/category-api/api/search?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: SearchResponse = await response.json();
return data;
} catch (error) {
console.error('searchMetrics error:', error);
throw error;
}
};
/**
* 从树中提取所有指标(用于前端搜索)
* @param nodes 树节点数组
* @returns 所有指标的扁平化数组
*/
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
const metrics: TreeMetric[] = [];
const traverse = (node: TreeNode) => {
if (node.metrics && node.metrics.length > 0) {
metrics.push(...node.metrics);
}
if (node.children && node.children.length > 0) {
node.children.forEach(traverse);
}
};
nodes.forEach(traverse);
return metrics;
};
/**
* 在树中查找节点
* @param nodes 树节点数组
* @param path 节点路径
* @returns 找到的节点或 null
*/
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) {
return node;
}
if (node.children) {
const found = findNodeByPath(node.children, path);
if (found) {
return found;
}
}
}
return null;
};
/**
* 获取节点的所有父节点路径
* @param path 节点路径(用 | 分隔)
* @returns 父节点路径数组
*/
export const getParentPaths = (path: string): string[] => {
const parts = path.split('|');
const parentPaths: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentPaths.push(parts.slice(0, i).join('|'));
}
return parentPaths;
};
/**
* 获取指标数据详情
* @param metricId 指标ID
* @param startDate 开始日期可选格式YYYY-MM-DD
* @param endDate 结束日期可选格式YYYY-MM-DD
* @param limit 返回数据条数可选默认100
* @returns 指标数据
*/
export const fetchMetricData = async (
metricId: string,
startDate?: string,
endDate?: string,
limit: number = 100
): Promise<MetricDataResponse> => {
try {
const params = new URLSearchParams({
metric_id: metricId,
limit: limit.toString(),
});
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const response = await fetch(`/category-api/api/metric-data?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: MetricDataResponse = await response.json();
return data;
} catch (error) {
console.error('fetchMetricData error:', error);
throw error;
}
};

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
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
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) {
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
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,
CreateCommentParams,
} 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
// 统一日志工具
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秒内相同日志只输出一次

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

View File

@@ -63,7 +63,7 @@ let dynamicNewsCardRenderCount = 0;
* @param {Object} trackingFunctions - PostHog 追踪函数集合
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
const DynamicNewsCardComponent = forwardRef(({
filters = {},
popularKeywords = [],
lastUpdateTime,
@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
'fourRowData.total': fourRowData.total,
});
// 根据模式选择数据源
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
// 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...]
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
const modeData = useMemo(
() => currentMode === 'four-row' ? fourRowData : verticalData,
[currentMode, fourRowData, verticalData]
);
const {
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
loading = false,
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
cachedPageCount = 0
} = modeData;
// 传递给 usePagination 的数据
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
// 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
const allCachedEventsByPage = useMemo(
() => currentMode === 'vertical' ? data : undefined,
[currentMode, data]
);
const allCachedEvents = useMemo(
() => currentMode === 'four-row' ? data : undefined,
[currentMode, data]
);
// 🔍 调试:选择的数据源
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
@@ -227,8 +236,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
// ========== 纵向模式 ==========
// 只在第1页时刷新避免打断用户浏览其他页
if (state.currentPage === 1) {
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
handlePageChange(1); // 清空缓存并刷新第1页
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表');
handlePageChange(1, true); // ⚡ 传递 force = true强制刷新第1页
toast({
title: '检测到新事件',
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;

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
* @param {boolean} props.hasMore - 是否还有更多数据
* @param {boolean} props.loading - 加载状态
*/
const VirtualizedFourRowGrid = forwardRef(({
const VirtualizedFourRowGridComponent = forwardRef(({
display = 'block',
events,
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;

View File

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

View File

@@ -13,7 +13,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import moment from 'moment';
import dayjs from 'dayjs';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton';
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
{/* 日期 */}
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
{moment(event.created_at).format('YYYY年MM月DD日')}
{dayjs(event.created_at).format('YYYY年MM月DD日')}
</Text>
</Flex>

View File

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

View File

@@ -8,7 +8,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment';
import dayjs from 'dayjs';
/**
* 交易日期信息提示组件
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
<FaCalendarAlt color="gray" size={12} />
<Text fontSize="xs" color={stockCountColor}>
涨跌幅数据{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import {
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import moment from 'moment';
import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { getChangeColor } from '../../../../utils/colorUtils';
@@ -33,7 +33,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
* @param {Function} props.onToggleFollow - 切换关注事件
* @param {string} props.borderColor - 边框颜色
*/
const DynamicNewsEventCard = ({
const DynamicNewsEventCard = React.memo(({
event,
index,
isFollowing,
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = ({
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
*/
const getTradingPeriod = (timestamp) => {
const eventTime = moment(timestamp);
const eventTime = dayjs(timestamp);
const hour = eventTime.hour();
const minute = eventTime.minute();
const timeInMinutes = hour * 60 + minute;
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = ({
color={timeLabelStyle.textColor}
lineHeight="1.3"
>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
{periodLabel && (
<>
{' • '}
@@ -317,6 +317,6 @@ const DynamicNewsEventCard = ({
</Card>
</VStack>
);
};
});
export default DynamicNewsEventCard;

View File

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

View File

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

View File

@@ -1,283 +0,0 @@
// src/views/Community/components/EventDetailModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import moment from 'moment';
const EventDetailModal = ({ visible, event, onClose }) => {
const [loading, setLoading] = useState(false);
const [eventDetail, setEventDetail] = useState(null);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [comments, setComments] = useState([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const loadEventDetail = async () => {
if (!event) return;
setLoading(true);
try {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setEventDetail(response.data);
}
} catch (error) {
logger.error('EventDetailModal', 'loadEventDetail', error, {
eventId: event?.id
});
} finally {
setLoading(false);
}
};
const loadComments = async () => {
if (!event) return;
setCommentsLoading(true);
try {
// 使用统一的posts API获取评论
const result = await eventService.getPosts(event.id);
if (result.success) {
setComments(result.data || []);
}
} catch (error) {
logger.error('EventDetailModal', 'loadComments', error, {
eventId: event?.id
});
} finally {
setCommentsLoading(false);
}
};
useEffect(() => {
if (visible && event) {
loadEventDetail();
loadComments();
}
}, [visible, event]);
const getImportanceColor = (importance) => {
const colors = {
S: 'red',
A: 'orange',
B: 'blue',
C: 'green'
};
return colors[importance] || 'default';
};
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
const renderPriceTag = (value, label) => {
if (value === null || value === undefined) return `${label}: --`;
const color = value > 0 ? '#ff4d4f' : '#52c41a';
const prefix = value > 0 ? '+' : '';
return (
<span>
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
</span>
);
};
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
// 使用统一的createPost API
const result = await eventService.createPost(event.id, {
content: commentText.trim(),
content_type: 'text'
});
if (result.success) {
message.success('评论发布成功');
setCommentText('');
// 重新加载评论列表
loadComments();
} else {
throw new Error(result.message || '评论失败');
}
} catch (e) {
message.error(e.message || '评论失败');
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={eventDetail?.title || '事件详情'}
open={visible}
onCancel={onClose}
width={800}
footer={null}
>
<Spin spinning={loading}>
{eventDetail && (
<>
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="创建时间">
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="创建者">
{eventDetail.creator?.username || 'Anonymous'}
</Descriptions.Item>
<Descriptions.Item label="重要性">
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}`} />
</Descriptions.Item>
<Descriptions.Item label="浏览数">
{eventDetail.view_count || 0}
</Descriptions.Item>
<Descriptions.Item label="涨幅统计" span={2}>
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
</Descriptions.Item>
<Descriptions.Item label="事件描述" span={2}>
{eventDetail.description}AI合成
</Descriptions.Item>
</Descriptions>
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
<div style={{ marginBottom: 24 }}>
<h4>相关概念</h4>
{eventDetail.keywords.map((keyword, index) => (
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
{keyword}
</Tag>
))}
</div>
)}
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
<div>
<h4>相关股票</h4>
<List
size="small"
dataSource={eventDetail.related_stocks}
renderItem={stock => (
<List.Item
actions={[
<Button
type="primary"
size="small"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
]}
>
<List.Item.Meta
title={`${stock.stock_name} (${stock.stock_code})`}
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : ''}
/>
{stock.change !== null && (
<Tag color={stock.change > 0 ? 'red' : 'green'}>
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
</Tag>
)}
</List.Item>
)}
/>
</div>
)}
{/* 讨论区 */}
<div style={{ marginTop: 24 }}>
<h4>讨论区</h4>
{/* 评论列表 */}
<div style={{ marginBottom: 24 }}>
<Spin spinning={commentsLoading}>
{comments.length === 0 ? (
<Empty
description="暂无评论"
style={{ padding: '20px 0' }}
/>
) : (
<List
itemLayout="vertical"
dataSource={comments}
renderItem={comment => (
<List.Item key={comment.id}>
<List.Item.Meta
title={
<div style={{ fontSize: '14px' }}>
<strong>{comment.author?.username || 'Anonymous'}</strong>
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
{moment(comment.created_at).format('MM-DD HH:mm')}
</span>
</div>
}
description={
<div style={{
fontSize: '14px',
lineHeight: '1.6',
marginTop: 8
}}>
{comment.content}
</div>
}
/>
</List.Item>
)}
/>
)}
</Spin>
</div>
{/* 评论输入框登录后可用未登录后端会返回401 */}
<div>
<h4>发表评论</h4>
<Input.TextArea
placeholder="说点什么..."
rows={3}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
maxLength={500}
showCount
/>
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
发布
</Button>
</div>
</div>
</div>
</>
)}
</Spin>
</Modal>
);
};
export default EventDetailModal;

View File

@@ -1,387 +0,0 @@
/* src/views/Community/components/EventList.css */
/* 时间轴容器样式 */
.event-timeline {
padding: 0 0 0 24px;
}
/* 时间轴圆点样式 */
.timeline-dot {
border: none !important;
cursor: pointer;
transition: all 0.3s;
}
/* 时间轴事件卡片 */
.timeline-event-card {
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.timeline-event-card:hover {
transform: translateX(8px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
border-color: #d9d9d9;
}
/* 重要性标记线 */
.importance-marker {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
transition: width 0.3s;
}
.timeline-event-card:hover .importance-marker {
width: 6px;
}
/* 事件标题 */
.event-title {
margin-bottom: 8px;
}
.event-title a {
color: #1890ff;
font-size: 16px;
font-weight: 500;
text-decoration: none;
transition: color 0.3s;
}
.event-title a:hover {
color: #40a9ff;
text-decoration: underline;
}
/* 事件元信息 */
.event-meta {
font-size: 12px;
color: #999;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.event-meta .anticon {
margin-right: 4px;
}
.event-meta .separator {
margin: 0;
color: #e8e8e8;
}
/* 事件描述 */
.event-description {
margin: 0 0 12px;
color: #666;
line-height: 1.6;
font-size: 14px;
}
/* 事件统计标签 */
.event-stats {
margin-bottom: 16px;
}
.event-stats .ant-tag {
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
}
/* 事件操作区域 */
.event-actions {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #999;
}
.event-actions > span {
margin-right: 0;
}
.event-actions .anticon {
margin-right: 4px;
}
/* 事件按钮 */
.event-buttons {
margin-left: auto;
}
.event-buttons .ant-btn {
font-size: 13px;
}
.event-buttons .ant-btn-sm {
height: 28px;
padding: 0 12px;
}
/* 重要性指示器 */
.importance-indicator {
text-align: right;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.importance-indicator .ant-badge {
display: block;
}
.importance-indicator .ant-avatar {
transition: all 0.3s;
}
.timeline-event-card:hover .importance-indicator .ant-avatar {
transform: scale(1.1);
}
.importance-label {
font-size: 12px;
color: #666;
font-weight: 500;
}
/* 分页容器 */
.pagination-container {
margin-top: 32px;
text-align: center;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 0;
color: #999;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.event-timeline {
padding-left: 0;
}
.timeline-event-card {
padding: 16px;
}
.event-title a {
font-size: 15px;
}
.event-description {
font-size: 13px;
}
.event-actions {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.event-buttons {
margin-left: 0;
width: 100%;
}
.event-buttons .ant-space {
width: 100%;
}
.event-buttons .ant-btn {
flex: 1;
}
.importance-indicator {
position: absolute;
top: 16px;
right: 16px;
}
.importance-label {
display: none;
}
}
/* 深色主题支持(可选) */
@media (prefers-color-scheme: dark) {
.timeline-event-card {
background: #1f1f1f;
border-color: #303030;
}
.timeline-event-card:hover {
border-color: #434343;
}
.event-title a {
color: #4096ff;
}
.event-description {
color: #bfbfbf;
}
.event-meta {
color: #8c8c8c;
}
}
/* 动画效果 */
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 时间轴项目动画 */
.ant-timeline-item {
animation: fadeInLeft 0.5s ease-out forwards;
opacity: 0;
}
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
/* 时间轴连接线样式 */
.ant-timeline-item-tail {
border-left-style: dashed;
border-left-width: 2px;
}
/* 涨跌幅标签特殊样式 */
.event-stats .ant-tag[color="#ff4d4f"] {
background-color: #fff1f0;
border-color: #ffccc7;
}
.event-stats .ant-tag[color="#52c41a"] {
background-color: #f6ffed;
border-color: #b7eb8f;
}
/* 快速查看和详细信息按钮悬停效果 */
.event-buttons .ant-btn-default:hover {
color: #40a9ff;
border-color: #40a9ff;
}
.event-buttons .ant-btn-primary {
background: #1890ff;
border-color: #1890ff;
}
.event-buttons .ant-btn-primary:hover {
background: #40a9ff;
border-color: #40a9ff;
}
/* 工具提示样式 */
.ant-tooltip-inner {
font-size: 12px;
}
/* 徽章计数样式 */
.importance-indicator .ant-badge-count {
font-size: 12px;
font-weight: bold;
min-width: 20px;
height: 20px;
line-height: 20px;
border-radius: 10px;
padding: 0 6px;
}
/* 加载状态动画 */
.timeline-event-card.loading {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 特殊重要性等级样式增强 */
.timeline-event-card[data-importance="S"] {
border-left: 4px solid #722ed1;
}
.timeline-event-card[data-importance="A"] {
border-left: 4px solid #ff4d4f;
}
.timeline-event-card[data-importance="B"] {
border-left: 4px solid #faad14;
}
.timeline-event-card[data-importance="C"] {
border-left: 4px solid #52c41a;
}
/* 时间轴左侧内容区域优化 */
.ant-timeline-item-content {
padding-bottom: 0;
min-height: auto;
}
/* 确保最后一个时间轴项目没有连接线 */
.ant-timeline-item:last-child .ant-timeline-item-tail {
display: none;
}
/* 打印样式优化 */
@media print {
.timeline-event-card {
page-break-inside: avoid;
border: 1px solid #000;
}
.event-buttons {
display: none;
}
.importance-marker {
width: 2px !important;
background: #000 !important;
}
}

View File

@@ -1,490 +0,0 @@
// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Flex,
Container,
useColorModeValue,
Switch,
FormControl,
FormLabel,
useToast,
Center,
Tooltip,
} from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
// 导入工具函数和常量
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { useEventNotifications } from '../../../hooks/useEventNotifications';
import { browserNotificationService } from '../../../services/browserNotificationService';
import { useNotification } from '../../../contexts/NotificationContext';
import { getImportanceConfig } from '../../../constants/importanceLevels';
// 导入子组件
import EventCard from './EventCard';
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const toast = useToast();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
// 从 NotificationContext 获取推送权限相关状态和方法
const { browserPermission, requestBrowserPermission } = useNotification();
// 实时事件推送集成
const { isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true,
onNewEvent: (event) => {
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
console.log('[EventList DEBUG] 事件数据:', event);
console.log('[EventList DEBUG] 事件 ID:', event?.id);
console.log('[EventList DEBUG] 事件标题:', event?.title);
logger.info('EventList', '收到新事件推送', event);
// 发送浏览器原生通知
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
if (browserPermission === 'granted') {
const importance = getImportanceConfig(event.importance);
const notification = browserNotificationService.sendNotification({
title: `🔔 ${importance.label}级事件`,
body: event.title,
tag: `event_${event.id}`,
data: {
link: `/event-detail/${event.id}`,
eventId: event.id,
},
autoClose: 10000, // 10秒后自动关闭
});
if (notification) {
browserNotificationService.setupClickHandler(notification, navigate);
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
}
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
}
console.log('[EventList DEBUG] 准备更新事件列表');
// 将新事件添加到列表顶部(防止重复)
setLocalEvents((prevEvents) => {
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
const exists = prevEvents.some(e => e.id === event.id);
console.log('[EventList DEBUG] 事件是否已存在:', exists);
if (exists) {
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
return prevEvents;
}
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
// 添加到顶部,最多保留 100 个
const updatedEvents = [event, ...prevEvents].slice(0, 100);
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
return updatedEvents;
});
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
}
});
// 同步外部 events 到 localEvents
useEffect(() => {
setLocalEvents(events);
}, [events]);
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
localEvents.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = getApiBase();
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 localEvents 更新时重跑
}, [localEvents]);
const toggleFollow = async (eventId) => {
try {
const base = getApiBase();
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 处理推送开关切换
const handlePushToggle = async (e) => {
const isChecked = e.target.checked;
if (isChecked) {
// 用户想开启推送
logger.info('EventList', '用户请求开启推送');
const permission = await requestBrowserPermission();
if (permission === 'denied') {
// 权限被拒绝,显示设置指引
logger.warn('EventList', '用户拒绝了推送权限');
toast({
title: '推送权限被拒绝',
description: '如需开启推送,请在浏览器设置中允许通知权限',
status: 'warning',
duration: 5000,
isClosable: true,
position: 'top',
});
} else if (permission === 'granted') {
logger.info('EventList', '推送权限已授予');
}
} else {
// 用户想关闭推送 - 提示需在浏览器设置中操作
logger.info('EventList', '用户尝试关闭推送');
toast({
title: '关闭推送通知',
description: '如需关闭,请在浏览器设置中撤销通知权限',
status: 'info',
duration: 5000,
isClosable: true,
position: 'top',
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 时间轴样式配置(固定使用轻量卡片样式)
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
// 计算要显示的页码数组(智能分页)
const getPageNumbers = () => {
const delta = 2; // 当前页左右各显示2个页码
const range = [];
const rangeWithDots = [];
// 始终显示第1页
range.push(1);
// 显示当前页附近的页码
for (let i = current - delta; i <= current + delta; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
// 始终显示最后一页(如果总页数>1
if (totalPages > 1) {
range.push(totalPages);
}
// 去重并排序
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
// 添加省略号
let prev = 0;
for (const page of uniqueRange) {
if (page - prev === 2) {
// 如果只差一个页码,直接显示
rangeWithDots.push(prev + 1);
} else if (page - prev > 2) {
// 如果差距大于2显示省略号
rangeWithDots.push('...');
}
rangeWithDots.push(page);
prev = page;
}
return rangeWithDots;
};
const pageNumbers = getPageNumbers();
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
...
</Text>
);
}
return (
<Button
key={page}
size="sm"
variant={current === page ? 'solid' : 'ghost'}
colorScheme={current === page ? 'blue' : 'gray'}
onClick={() => onChange(page)}
>
{page}
</Button>
);
})}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" pb={8}>
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制固定sticky - 铺满全宽 */}
<Box
position="sticky"
top={0}
zIndex={10}
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
backdropFilter="blur(10px)"
boxShadow="sm"
mb={4}
py={2}
w="100%"
>
<Container maxW="container.xl">
<Flex justify="space-between" align="center">
{/* 左侧占位 */}
<Box key="left-spacer" flex="1" />
{/* 中间:分页器 */}
{pagination.total > 0 && localEvents.length > 0 ? (
<Flex key="pagination-controls" align="center" gap={2}>
<Button
key="prev-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current - 1)}
isDisabled={pagination.current === 1}
>
上一页
</Button>
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
{pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)}
</Text>
<Button
key="next-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current + 1)}
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
>
下一页
</Button>
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
{pagination.total}
</Text>
</Flex>
) : (
<Box key="center-spacer" flex="1" />
)}
{/* 右侧:控制按钮 */}
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
{/* WebSocket 连接状态 */}
<Badge
key="websocket-status"
colorScheme={isConnected ? 'green' : 'red'}
fontSize="xs"
px={2}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时' : '🔴 离线'}
</Badge>
{/* 桌面推送开关 */}
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
推送
</FormLabel>
<Tooltip
label={
browserPermission === 'granted'
? '桌面推送已开启'
: browserPermission === 'denied'
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
: '点击开启桌面推送通知'
}
placement="top"
>
<Switch
id="push-notification"
size="sm"
isChecked={browserPermission === 'granted'}
onChange={handlePushToggle}
colorScheme="green"
/>
</Tooltip>
</FormControl>
{/* 视图切换控制 */}
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
精简
</FormLabel>
<Switch
id="compact-mode"
size="sm"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
</Flex>
</Container>
</Box>
{/* 事件列表内容 */}
<Container maxW="container.xl">
{localEvents.length > 0 ? (
<VStack key="event-list" align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
<EventCard
event={event}
index={index}
isCompactMode={isCompactMode}
isFollowing={!!followingMap[event.id]}
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
onEventClick={onEventClick}
onTitleClick={handleTitleClick}
onViewDetail={handleViewDetailClick}
onToggleFollow={toggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</VStack>
) : (
<Center key="empty-state" h="300px">
<VStack spacing={4}>
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
<Text key="empty-text" color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
key="bottom-pagination"
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;

View File

@@ -1,818 +0,0 @@
// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Tag,
TagLabel,
TagLeftIcon,
Flex,
Avatar,
Tooltip,
IconButton,
Divider,
Container,
useColorModeValue,
Circle,
Stat,
StatNumber,
StatHelpText,
StatArrow,
ButtonGroup,
Heading,
SimpleGrid,
Card,
CardBody,
Center,
Link,
Spacer,
Switch,
FormControl,
FormLabel,
} from '@chakra-ui/react';
import {
ViewIcon,
ChatIcon,
StarIcon,
TimeIcon,
InfoIcon,
WarningIcon,
WarningTwoIcon,
CheckCircleIcon,
TriangleUpIcon,
TriangleDownIcon,
ArrowForwardIcon,
ExternalLinkIcon,
ViewOffIcon,
} from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
const getPriceChangeColor = (value) => {
if (value === null || value === undefined) return 'gray.500';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨用红色,根据涨幅大小使用不同深浅
if (absValue >= 3) return 'red.600'; // 深红色3%以上
if (absValue >= 1) return 'red.500'; // 中红色1-3%
return 'red.400'; // 浅红色0-1%
} else if (value < 0) {
// 下跌用绿色,根据跌幅大小使用不同深浅
if (absValue >= 3) return 'green.600'; // 深绿色3%以上
if (absValue >= 1) return 'green.500'; // 中绿色1-3%
return 'green.400'; // 浅绿色0-1%
}
return 'gray.500';
};
const getPriceChangeBg = (value) => {
if (value === null || value === undefined) return 'gray.50';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨背景色
if (absValue >= 3) return 'red.100'; // 深色背景3%以上
if (absValue >= 1) return 'red.50'; // 中色背景1-3%
return 'red.50'; // 浅色背景0-1%
} else if (value < 0) {
// 下跌背景色
if (absValue >= 3) return 'green.100'; // 深色背景3%以上
if (absValue >= 1) return 'green.50'; // 中色背景1-3%
return 'green.50'; // 浅色背景0-1%
}
return 'gray.50';
};
const getPriceChangeBorderColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨边框色
if (absValue >= 3) return 'red.500'; // 深边框3%以上
if (absValue >= 1) return 'red.400'; // 中边框1-3%
return 'red.300'; // 浅边框0-1%
} else if (value < 0) {
// 下跌边框色
if (absValue >= 3) return 'green.500'; // 深边框3%以上
if (absValue >= 1) return 'green.400'; // 中边框1-3%
return 'green.300'; // 浅边框0-1%
}
return 'gray.300';
};
// 重要性等级配置 - 金融配色方案
const importanceLevels = {
'S': {
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
},
'A': {
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
},
'B': {
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
},
'C': {
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
}
};
const getImportanceConfig = (importance) => {
return importanceLevels[importance] || importanceLevels['C'];
};
// 自定义的涨跌箭头组件(修复颜色问题)
const PriceArrow = ({ value }) => {
if (value === null || value === undefined) return null;
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
const color = value > 0 ? 'red.500' : 'green.500';
return <Icon color={color} boxSize="16px" />;
};
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
events.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 events 更新时重跑
}, [events]);
const toggleFollow = async (eventId) => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const renderPriceChange = (value, label) => {
if (value === null || value === undefined) {
return (
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
</Tag>
);
}
const absValue = Math.abs(value);
const isPositive = value > 0;
// 根据涨跌幅大小选择不同的颜色深浅
let colorScheme = 'gray';
let variant = 'solid';
if (isPositive) {
// 上涨用红色系
if (absValue >= 3) {
colorScheme = 'red';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'red';
variant = 'subtle'; // 中等
} else {
colorScheme = 'red';
variant = 'outline'; // 浅色
}
} else {
// 下跌用绿色系
if (absValue >= 3) {
colorScheme = 'green';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'green';
variant = 'subtle'; // 中等
} else {
colorScheme = 'green';
variant = 'outline'; // 浅色
}
}
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
return (
<Tag
size="lg"
colorScheme={colorScheme}
borderRadius="full"
variant={variant}
boxShadow="sm"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={Icon} boxSize="16px" />
<TagLabel fontSize="sm" fontWeight="bold">
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
</TagLabel>
</Tag>
);
};
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 精简模式的事件渲染
const renderCompactEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="32px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="sm"
boxShadow="sm"
border="2px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="60px"
/>
</VStack>
{/* 精简事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-1px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={3}
>
<CardBody p={4}>
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
{/* 左侧:标题和时间 */}
<VStack align="start" spacing={2} flex="1" minW="200px">
<Heading
size="sm"
color={linkColor}
_hover={{ textDecoration: 'underline' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
noOfLines={1}
>
{event.title}
</Heading>
<HStack spacing={2} fontSize="xs" color={mutedColor}>
<TimeIcon />
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
<Text></Text>
<Text>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
</VStack>
{/* 右侧:涨跌幅指标 */}
<HStack spacing={3}>
<Tooltip label="平均涨幅" placement="top">
<Box
px={3}
py={1}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="1px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
>
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text
fontSize="sm"
fontWeight="bold"
color={getPriceChangeColor(event.related_avg_chg)}
>
{event.related_avg_chg != null
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Box>
</Tooltip>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详情
</Button>
<Button
size="sm"
variant={isFollowing ? 'solid' : 'outline'}
colorScheme="yellow"
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
</Button>
</HStack>
</Flex>
</CardBody>
</Card>
</HStack>
);
};
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
const renderDetailedEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="40px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="lg"
boxShadow="md"
border="3px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="100px"
/>
</VStack>
{/* 事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={4}
>
<CardBody p={5}>
<VStack align="stretch" spacing={3}>
{/* 标题和重要性标签 */}
<Flex align="center" justify="space-between">
<Tooltip
label="点击查看事件详情"
placement="top"
hasArrow
openDelay={500}
>
<Heading
size="md"
color={linkColor}
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
>
{event.title}
</Heading>
</Tooltip>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
>
{importance.label}优先级
</Badge>
</Flex>
{/* 元信息 */}
<HStack spacing={4} fontSize="sm">
<HStack
bg="blue.50"
px={3}
py={1}
borderRadius="full"
color="blue.700"
fontWeight="medium"
>
<TimeIcon />
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
</HStack>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
{/* 描述 */}
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
{event.description}
</Text>
{/* 价格变化指标 */}
<Box
bg={useColorModeValue('gradient.subtle', 'gray.700')}
bgGradient="linear(to-r, gray.50, white)"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
平均涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
{event.related_avg_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text fontWeight="bold">
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_max_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
最大涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
{event.related_max_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_max_chg} />
<Text fontWeight="bold">
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_week_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
周涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
{event.related_week_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_week_chg} />
<Text fontWeight="bold">
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
</SimpleGrid>
</Box>
<Divider />
{/* 统计信息和操作按钮 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
<HStack spacing={6}>
<Tooltip label="浏览量" placement="top">
<HStack spacing={1} color={mutedColor}>
<ViewIcon />
<Text fontSize="sm">{event.view_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="帖子数" placement="top">
<HStack spacing={1} color={mutedColor}>
<ChatIcon />
<Text fontSize="sm">{event.post_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="关注数" placement="top">
<HStack spacing={1} color={mutedColor}>
<StarIcon />
<Text fontSize="sm">{followerCount}</Text>
</HStack>
</Tooltip>
</HStack>
<ButtonGroup size="sm" spacing={2}>
<Button
variant="outline"
colorScheme="gray"
leftIcon={<ViewIcon />}
onClick={(e) => {
e.stopPropagation();
onEventClick(event);
}}
>
快速查看
</Button>
<Button
colorScheme="blue"
leftIcon={<ExternalLinkIcon />}
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详细信息
</Button>
<Button
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
</Button>
</ButtonGroup>
</Flex>
</VStack>
</CardBody>
</Card>
</HStack>
);
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
size="sm"
variant={current === pageNum ? 'solid' : 'ghost'}
colorScheme={current === pageNum ? 'blue' : 'gray'}
onClick={() => onChange(pageNum)}
>
{pageNum}
</Button>
);
})}
{totalPages > 5 && <Text>...</Text>}
{totalPages > 5 && (
<Button
size="sm"
variant={current === totalPages ? 'solid' : 'ghost'}
colorScheme={current === totalPages ? 'blue' : 'gray'}
onClick={() => onChange(totalPages)}
>
{totalPages}
</Button>
)}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 视图切换控制 */}
<Flex justify="flex-end" mb={6}>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
</FormLabel>
<Switch
id="compact-mode"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
{events.length > 0 ? (
<VStack align="stretch" spacing={0}>
{events.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}
</Box>
))}
</VStack>
) : (
<Center h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;

View File

@@ -1,75 +0,0 @@
// src/views/Community/components/EventListSection.js
// 事件列表区域组件包含Loading、Empty、List三种状态
import React from 'react';
import {
Box,
Center,
VStack,
Spinner,
Text
} from '@chakra-ui/react';
import EventList from './EventList';
/**
* 事件列表区域组件
* @param {boolean} loading - 加载状态
* @param {Array} events - 事件列表
* @param {Object} pagination - 分页信息
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
*/
const EventListSection = ({
loading,
events,
pagination,
onPageChange,
onEventClick,
onViewDetail
}) => {
// ✅ 最小高度,避免加载后高度突变
const minHeight = '600px';
// Loading 状态
if (loading) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
</Box>
);
}
// Empty 状态
if (!events || events.length === 0) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
</Box>
);
}
// List 状态
return (
<Box minH={minHeight}>
<EventList
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</Box>
);
};
export default EventListSection;

View File

@@ -1,42 +0,0 @@
// src/views/Community/components/EventTimelineHeader.js
// 事件时间轴标题组件
import React from 'react';
import {
Flex,
VStack,
HStack,
Heading,
Text,
Badge
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
/**
* 事件时间轴标题组件
* @param {Date} lastUpdateTime - 最后更新时间
*/
const EventTimelineHeader = ({ lastUpdateTime }) => {
return (
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时事件</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="green">全网监控</Badge>
<Badge colorScheme="orange">智能捕获</Badge>
<Badge colorScheme="purple">深度分析</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime.toLocaleTimeString()}
</Text>
</Flex>
);
};
export default EventTimelineHeader;

View File

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

View File

@@ -1,34 +0,0 @@
// src/views/Community/components/ImportanceLegend.js
import React from 'react';
import { Card, Space, Badge } from 'antd';
const ImportanceLegend = () => {
const levels = [
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
];
return (
<Card title="重要性等级说明" className="importance-legend">
<Space direction="vertical" style={{ width: '100%' }}>
{levels.map(item => (
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
<Badge
color={item.color}
text={
<span>
<strong style={{ marginRight: 8 }}>{item.level}</strong>
{item.description}
</span>
}
/>
</div>
))}
</Space>
</Card>
);
};
export default ImportanceLegend;

View File

@@ -1,78 +0,0 @@
// src/views/Community/components/IndustryCascader.js
import React, { useState, useCallback } from 'react';
import { Card, Form, Cascader } from 'antd';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { logger } from '../../../utils/logger';
const IndustryCascader = ({ onFilterChange, loading }) => {
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
// 使用 Redux 获取行业数据
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = useCallback(async () => {
if (!industryData || industryData.length === 0) {
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
await dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// Cascader 选择变化
const handleIndustryCascaderChange = (value, selectedOptions) => {
setIndustryCascaderValue(value);
if (value && value.length > 0) {
// value[0] = 分类体系名称
// value[1...n] = 行业代码(一级~四级)
const industryCode = value[value.length - 1]; // 最后一级的 code
const classification = value[0]; // 分类体系名称
onFilterChange('industry_classification', classification);
onFilterChange('industry_code', industryCode);
logger.debug('IndustryCascader', 'Cascader 选择变化', {
value,
classification,
industryCode,
path: selectedOptions.map(o => o.label).join(' > ')
});
} else {
// 清空
onFilterChange('industry_classification', '');
onFilterChange('industry_code', '');
}
};
return (
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
<Form layout="vertical">
<Form.Item label="选择行业分类体系和具体行业">
<Cascader
options={industryData || []}
value={industryCascaderValue}
onChange={handleIndustryCascaderChange}
onFocus={handleCascaderFocus}
changeOnSelect
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
disabled={loading || industryLoading}
loading={industryLoading}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
showSearch={{
filter: (inputValue, path) =>
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
}}
style={{ width: '100%' }}
/>
</Form.Item>
</Form>
</Card>
);
};
export default IndustryCascader;

View File

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

View File

@@ -1,300 +0,0 @@
// src/views/Community/components/MarketReviewCard.js
// 市场复盘组件(左右布局:事件列表 | 事件详情)
import React, { forwardRef, useState } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
Center,
Spinner,
useColorModeValue,
Grid,
GridItem,
} from '@chakra-ui/react';
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
import moment from 'moment';
import CompactEventCard from './EventCard/CompactEventCard';
import EventHeader from './EventCard/EventHeader';
import EventStats from './EventCard/EventStats';
import EventFollowButton from './EventCard/EventFollowButton';
import EventPriceDisplay from './EventCard/EventPriceDisplay';
import EventDescription from './EventCard/EventDescription';
import { getImportanceConfig } from '../../../constants/importanceLevels';
/**
* 市场复盘 - 左右布局卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Function} onToggleFollow - 切换关注回调
* @param {Object} ref - 用于滚动的ref
*/
const MarketReviewCard = forwardRef(({
events,
loading,
lastUpdateTime,
onEventClick,
onViewDetail,
onToggleFollow,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const textColor = useColorModeValue('gray.700', 'gray.200');
const selectedBg = useColorModeValue('blue.50', 'blue.900');
// 选中的事件
const [selectedEvent, setSelectedEvent] = useState(null);
// 时间轴样式配置
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 处理事件点击
const handleEventClick = (event) => {
setSelectedEvent(event);
if (onEventClick) {
onEventClick(event);
}
};
// 渲染右侧事件详情
const renderEventDetail = () => {
if (!selectedEvent) {
return (
<Center h="full" minH="400px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
请从左侧选择事件查看详情
</Text>
</VStack>
</Center>
);
}
const importance = getImportanceConfig(selectedEvent.importance);
return (
<Card
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="md"
h="full"
>
<CardBody p={6}>
<VStack align="stretch" spacing={4}>
{/* 第一行:标题+优先级 | 统计+关注 */}
<Flex align="center" justify="space-between" gap={3}>
{/* 左侧:标题 + 优先级标签 */}
<EventHeader
title={selectedEvent.title}
importance={selectedEvent.importance}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onViewDetail) {
onViewDetail(e, selectedEvent.id);
}
}}
linkColor={linkColor}
compact={false}
size="lg"
/>
{/* 右侧:统计数据 + 关注按钮 */}
<HStack spacing={4} flexShrink={0}>
{/* 统计数据 */}
<EventStats
viewCount={selectedEvent.view_count}
postCount={selectedEvent.post_count}
followerCount={selectedEvent.follower_count}
size="md"
spacing={4}
display="flex"
mutedColor={mutedColor}
/>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={false}
followerCount={selectedEvent.follower_count}
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
size="sm"
showCount={false}
/>
</HStack>
</Flex>
{/* 第二行:价格标签 | 时间+作者 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
{/* 左侧:价格标签 */}
<EventPriceDisplay
avgChange={selectedEvent.related_avg_chg}
maxChange={selectedEvent.related_max_chg}
weekChange={selectedEvent.related_week_chg}
compact={false}
/>
{/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}>
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
</HStack>
</Flex>
{/* 第三行:描述文字 */}
<EventDescription
description={selectedEvent.description}
textColor={textColor}
minLength={200}
noOfLines={10}
/>
</VStack>
</CardBody>
</Card>
);
};
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>市场复盘</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="orange">复盘</Badge>
<Badge colorScheme="purple">总结</Badge>
<Badge colorScheme="gray">完整</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* Loading 状态 */}
{loading && (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载复盘数据...</Text>
</VStack>
</Center>
)}
{/* Empty 状态 */}
{!loading && (!events || events.length === 0) && (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
</VStack>
</Center>
)}
{/* 左右布局:事件列表 | 事件详情 */}
{!loading && events && events.length > 0 && (
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
{/* 左侧:事件列表 (33.3%) */}
<GridItem>
<Box
overflowY="auto"
maxH="600px"
pr={2}
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: useColorModeValue('#f1f1f1', '#2D3748'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('#888', '#4A5568'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('#555', '#718096'),
},
}}
>
<VStack align="stretch" spacing={2}>
{events.map((event, index) => (
<Box
key={event.id}
onClick={() => handleEventClick(event)}
cursor="pointer"
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
borderRadius="md"
transition="all 0.2s"
_hover={{ bg: selectedBg }}
>
<CompactEventCard
event={event}
index={index}
isFollowing={false}
followerCount={event.follower_count || 0}
onEventClick={() => handleEventClick(event)}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEventClick(event);
}}
onViewDetail={onViewDetail}
onToggleFollow={() => {}}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</VStack>
</Box>
</GridItem>
{/* 右侧:事件详情 (66.7%) */}
<GridItem>
{renderEventDetail()}
</GridItem>
</Grid>
)}
</CardBody>
</Card>
);
});
MarketReviewCard.displayName = 'MarketReviewCard';
export default MarketReviewCard;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,898 +0,0 @@
// src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import PopularKeywords from './PopularKeywords';
import TradingTimeFilter from './TradingTimeFilter';
const { Option } = AntSelect;
const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize, // 每页显示数量
trackingFunctions = {} // PostHog 追踪函数集合
}) => {
// 其他状态
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]);
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
// ✅ 本地输入状态 - 管理用户的实时输入
const [inputValue, setInputValue] = useState('');
// 使用 Redux 获取行业数据
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
// 加载行业数据函数
const loadIndustryData = useCallback(() => {
if (!industryData) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 搜索触发函数
const triggerSearch = useCallback((params) => {
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
params: params,
timestamp: new Date().toISOString()
});
onSearch(params);
}, [onSearch]);
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
params: params,
delayMs: 300
});
triggerSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [triggerSearch]);
// 加载所有股票数据
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
count: response.data.length
});
}
};
loadStocks();
}, []);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
// 辅助函数:递归查找行业代码的完整路径
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// ✅ 从 props.filters 初始化筛选条件和输入框值
useEffect(() => {
if (!filters) return;
// 初始化排序
if (filters.sort) setSort(filters.sort);
// 初始化重要性(字符串解析为数组)
if (filters.importance) {
const importanceArray = filters.importance === 'all'
? [] // 'all' 对应空数组(不显示任何选中)
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
setImportance(importanceArray);
logger.debug('UnifiedSearchBox', '初始化重要性', {
filters_importance: filters.importance,
importanceArray
});
} else {
setImportance([]);
}
// ✅ 初始化行业分类(需要 industryData 加载完成)
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
logger.debug('UnifiedSearchBox', '初始化行业分类', {
industry_code: filters.industry_code,
path
});
}
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
// 如果 filters 中没有行业代码,但本地有值,清空本地值
setIndustryValue([]);
logger.debug('UnifiedSearchBox', '清空行业分类filters中无值');
}
// ✅ 同步 filters.q 到输入框显示值
if (filters.q) {
setInputValue(filters.q);
} else if (!filters.q) {
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
// ✅ 初始化时间筛选(从 filters 中恢复)
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
// 根据参数推断按钮 key
let inferredKey = 'custom';
let inferredLabel = '';
if (filters.recent_days) {
// 推断是否是预设按钮
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
} else if (filters.recent_days === '30') {
inferredKey = 'month';
inferredLabel = '近一月';
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
}
// 从 filters 重建 tradingTimeRange 状态
const timeRange = {
start_date: filters.start_date || '',
end_date: filters.end_date || '',
recent_days: filters.recent_days || '',
label: inferredLabel,
key: inferredKey
};
setTradingTimeRange(timeRange);
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
filters_time: {
start_date: filters.start_date,
end_date: filters.end_date,
recent_days: filters.recent_days
},
tradingTimeRange: timeRange
});
} else if (!hasTimeInFilters && tradingTimeRange) {
// 如果 filters 中没有时间参数,但本地有值,清空本地值
setTradingTimeRange(null);
logger.debug('UnifiedSearchBox', '清空时间筛选filters中无值');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
// 使用 stockService 进行模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
// 转换为 AutoComplete 选项格式
const options = results.map(stock => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
</div>
),
// 保存完整的股票信息,用于选中后显示
stockInfo: stock
}));
setStockOptions(options);
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
query: value,
resultCount: options.length
});
};
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code,
name: stockInfo.name
});
// 🎯 追踪股票点击
if (trackingFunctions.trackRelatedStockClicked) {
trackingFunctions.trackRelatedStockClicked({
stockCode: stockInfo.code,
stockName: stockInfo.name,
source: 'search_box_autocomplete',
timestamp: new Date().toISOString(),
});
}
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
const params = buildFilterParams({
q: stockInfo.code, // 使用股票代码作为搜索关键词
industry_code: ''
});
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
triggerSearch(params);
}
};
// ✅ 重要性变化(立即执行)- 支持多选
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 转换为逗号分隔字符串传给后端(空数组表示"全部"
const importanceStr = value.length === 0 ? 'all' : value.join(',');
// 🎯 追踪筛选操作
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'importance',
filterValue: importanceStr,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ importance: importanceStr });
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 排序变化(立即触发搜索)
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪排序操作
if (trackingFunctions.trackNewsSorted) {
trackingFunctions.trackNewsSorted({
sortBy: value,
previousSortBy: sort,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 行业分类变化(立即触发搜索)
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪行业筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'industry',
filterValue: value?.[value.length - 1] || '',
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
});
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
const handleKeywordClick = (keyword) => {
// 更新输入框显示
setInputValue(keyword);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪热门关键词点击
if (trackingFunctions.trackNewsSearched) {
trackingFunctions.trackNewsSearched({
searchQuery: keyword,
searchType: 'popular_keyword',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
});
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
triggerSearch(params);
};
// ✅ 交易时段筛选变化(立即触发搜索)
const handleTradingTimeChange = (timeConfig) => {
if (!timeConfig) {
// 清空筛选
setTradingTimeRange(null);
// 🎯 追踪时间筛选清空
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: 'cleared',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
return;
}
const { range, type, label, key } = timeConfig;
let params = {};
if (type === 'recent_days') {
// 近一周/近一月使用 recent_days
params.recent_days = range;
params.start_date = '';
params.end_date = '';
} else {
// 其他使用 start_date + end_date
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
params.recent_days = '';
}
setTradingTimeRange({ ...params, label, key });
// 🎯 追踪时间筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: label,
timeRangeType: type,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig,
params: searchParams
});
triggerSearch(searchParams);
};
// 主搜索(点击搜索按钮或回车)
const handleMainSearch = () => {
// 取消之前的防抖
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
const params = buildFilterParams({
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
industry_code: ''
});
// 🎯 追踪搜索操作
if (trackingFunctions.trackNewsSearched && inputValue) {
trackingFunctions.trackNewsSearched({
searchQuery: inputValue,
searchType: 'main_search',
filters: params,
timestamp: new Date().toISOString(),
});
}
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
});
triggerSearch(params);
};
// ✅ 处理输入变化 - 更新本地输入状态
const handleInputChange = (value) => {
logger.debug('UnifiedSearchBox', '输入变化', { value });
setInputValue(value);
};
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
const buildFilterParams = useCallback((overrides = {}) => {
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
overrides: overrides,
currentState: {
sort,
importance,
industryValue,
'filters.q': filters.q,
mode,
pageSize
}
});
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === 'returns_avg') {
actualSort = 'returns';
returnType = 'avg';
} else if (sortValue === 'returns_week') {
actualSort = 'returns';
returnType = 'week';
}
// 处理重要性参数:数组转换为逗号分隔字符串
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue = importanceValue.length === 0
? 'all'
: importanceValue.join(',');
}
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: actualSort,
importance: importanceValue,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
// 交易时段筛选参数
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级
...overrides,
page: 1,
per_page: overrides.mode === 'four-row' ? 30: 10
};
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
delete result.per_page;
// 添加 return_type 参数(如果需要)
if (returnType) {
result.return_type = returnType;
}
// 添加 mode 和 per_page 参数(如果提供了的话)
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize; // 后端实际使用的参数
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance([]); // 改为空数组
setTradingTimeRange(null); // 清空交易时段筛选
// 🎯 追踪筛选重置
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'reset',
filterValue: 'all_filters_cleared',
timestamp: new Date().toISOString(),
});
}
// 输出重置后的完整参数
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all', // 传给后端时转为'all'
start_date: '',
end_date: '',
recent_days: '',
page: 1,
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
};
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
onSearch(resetParams);
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
const filterTags = useMemo(() => {
const tags = [];
// 搜索关键词标签 - 从 filters.q 读取
if (filters.q) {
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
}
// 行业标签
if (industryValue && industryValue.length > 0 && industryData) {
// 递归查找每个层级的 label
const findLabel = (code, data) => {
for (const item of data) {
if (code.startsWith(item.value)) {
if (item.value === code) {
return item.label;
} else {
return findLabel(code, item.children);
}
}
}
return null;
};
// 只显示最后一级的 label
const lastLevelCode = industryValue[industryValue.length - 1];
const lastLevelLabel = findLabel(lastLevelCode, industryData);
tags.push({
key: 'industry',
label: `行业: ${lastLevelLabel}`
});
}
// 交易时段筛选标签
if (tradingTimeRange?.label) {
tags.push({
key: 'trading_time',
label: `时间: ${tradingTimeRange.label}`
});
}
// 重要性标签(多选合并显示为单个标签)
if (importance && importance.length > 0) {
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
let sortLabel;
if (sort === 'hot') sortLabel = '最热';
else if (sort === 'importance') sortLabel = '重要性';
else if (sort === 'returns_avg') sortLabel = '平均收益率';
else if (sort === 'returns_week') sortLabel = '周收益率';
else sortLabel = sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags;
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
// ✅ 移除单个标签 - 构建新参数并触发搜索
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
const params = buildFilterParams({ q: '' });
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
triggerSearch(params);
} else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
} else if (key === 'trading_time') {
// 清除交易时段筛选
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
} else if (key === 'importance') {
// 重置重要性为空数组(传给后端为'all'
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}
};
return (
<div style={{padding: '8px'}}>
{/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="small" wrap>
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业分类"
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 160 }}
size="small"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 120 }}
size="small"
placeholder="全部"
maxTagCount={3}
>
<Option value="S">极高</Option>
<Option value="A"></Option>
<Option value="B"></Option>
<Option value="C"></Option>
</AntSelect>
</Space>
{/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
<SearchOutlined
onClick={handleMainSearch}
style={{
fontSize: 14,
padding: '5px 8px',
background: '#e6f7ff',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#1890ff',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#096dd9';
e.currentTarget.style.background = '#bae7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
/>
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onSearch={handleSearch}
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
style={{ flex: 1 }}
size="small"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
</Space.Compact>
{/* 重置按钮 - 现代化设计 */}
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="small"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff4d4f';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.backgroundColor = '#fff1f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = '#666';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
重置
</Button>
</Space>
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 100 }}
size="small"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
<Option value="returns_avg">平均收益率</Option>
<Option value="returns_week">周收益率</Option>
</AntSelect>
</Space>
</Space>
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
{/* 交易时段筛选 */}
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
/>
</Space>
{/* 第二行:热门概念 */}
<div style={{ marginTop: 2 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
</div>
);
};
export default UnifiedSearchBox;

View File

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

View File

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

View File

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

View File

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

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;

Some files were not shown because too many files have changed in this diff Show More