Compare commits

..

47 Commits

Author SHA1 Message Date
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
101 changed files with 12633 additions and 31060 deletions

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 优化

385
app.py
View File

@@ -1127,36 +1127,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 +1258,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 +1291,90 @@ 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
subscription_type = 'new'
current_plan = None
current_cycle = None
remaining_value = calculate_remaining_value(current_sub, current_plan)
# 6. 计算升级差价
upgrade_amount = max(0, new_price - remaining_value)
# 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 and current_sub.subscription_type in ['pro', 'max']:
# 如果当前是付费用户,则为续费
is_renewal = True
subscription_type = 'renew'
current_plan = current_sub.subscription_type
current_cycle = current_sub.billing_cycle
# 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,
'subscription_type': subscription_type,
'current_plan': current_plan,
'current_cycle': current_cycle,
'new_plan_price': price,
'original_amount': price,
'discount_amount': 0,
'final_amount': upgrade_amount,
'promo_code': None
'final_amount': 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, 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, price)
result['discount_amount'] = float(discount)
result['final_amount'] = 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 +1622,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 +1656,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({
@@ -1630,7 +1684,16 @@ def calculate_subscription_price():
@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 +1701,19 @@ 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
# 创建订单
try:
@@ -1663,48 +1724,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 +3460,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 +3490,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 +4177,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

View File

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

427
database_migration.sql Normal file
View File

@@ -0,0 +1,427 @@
-- ============================================
-- 订阅支付系统数据库迁移 SQL
-- 版本: v2.0.0
-- 日期: 2025-11-19
-- ============================================
-- ============================================
-- 第一步: 备份现有数据
-- ============================================
-- 创建备份表
CREATE TABLE IF NOT EXISTS user_subscriptions_backup AS SELECT * FROM user_subscriptions;
CREATE TABLE IF NOT EXISTS payment_orders_backup AS SELECT * FROM payment_orders;
CREATE TABLE IF NOT EXISTS subscription_plans_backup AS SELECT * FROM subscription_plans;
-- ============================================
-- 第二步: 删除旧表(先删除外键依赖的表)
-- ============================================
DROP TABLE IF EXISTS subscription_upgrades; -- 删除升级表,不再使用
DROP TABLE IF EXISTS promo_code_usage; -- 暂时删除,稍后重建
DROP TABLE IF EXISTS payment_orders; -- 删除旧订单表
DROP TABLE IF EXISTS user_subscriptions; -- 删除旧订阅表
DROP TABLE IF EXISTS subscription_plans; -- 删除旧套餐表
-- ============================================
-- 第三步: 创建新表结构
-- ============================================
-- 1. 订阅套餐表(重构)
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='订阅套餐配置表';
-- 2. 用户订阅记录表(重构)
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, free',
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 DEFAULT 0 COMMENT '实际支付金额',
original_price DECIMAL(10,2) NOT NULL DEFAULT 0 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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
-- 3. 支付订单表(重构)
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
-- 4. 优惠码使用记录表(重建)
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
-- ============================================
-- 第四步: 插入初始数据
-- ============================================
-- 插入套餐数据
INSERT INTO subscription_plans (
plan_code,
plan_name,
description,
price_monthly,
price_quarterly,
price_semiannual,
price_yearly,
features,
display_order,
is_active
) VALUES
(
'pro',
'Pro 专业版',
'为专业投资者打造,解锁高级分析功能',
299.00,
799.00,
1499.00,
2699.00,
JSON_ARRAY(
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 50家/月',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 100天',
'涨停板块数据分析',
'个股涨停分析'
),
1,
TRUE
),
(
'max',
'Max 旗舰版',
'旗舰级体验,无限制使用所有功能',
599.00,
1599.00,
2999.00,
5399.00,
JSON_ARRAY(
'全部 Pro 版功能',
'板块深度分析(AI)',
'个股深度分析(AI) - 无限制',
'历史时间轴查询 - 无限制',
'概念高频更新',
'优先客服支持',
'独家功能抢先体验'
),
2,
TRUE
);
-- ============================================
-- 第五步: 数据迁移(可选)
-- ============================================
-- 如果需要迁移旧数据,取消以下注释:
/*
-- 迁移旧的用户订阅数据
INSERT INTO user_subscriptions (
user_id,
subscription_id,
plan_code,
billing_cycle,
start_date,
end_date,
status,
is_current,
paid_amount,
original_price,
subscription_type,
auto_renew,
created_at
)
SELECT
user_id,
CONCAT('SUB_', id, '_', UNIX_TIMESTAMP(NOW())), -- 生成订阅ID
subscription_type, -- 将 subscription_type 映射为 plan_code
COALESCE(billing_cycle, 'yearly'), -- 默认年付
COALESCE(start_date, NOW()),
COALESCE(end_date, DATE_ADD(NOW(), INTERVAL 365 DAY)),
subscription_status,
TRUE, -- 设为当前订阅
0, -- 旧数据没有支付金额,设为0
0, -- 旧数据没有原价,设为0
'new', -- 默认为新购
COALESCE(auto_renewal, FALSE),
created_at
FROM user_subscriptions_backup
WHERE subscription_type IN ('pro', 'max'); -- 只迁移付费用户
*/
-- ============================================
-- 第六步: 创建免费订阅记录(为所有用户)
-- ============================================
-- 为所有现有用户创建免费订阅记录(如果没有付费订阅)
/*
INSERT INTO user_subscriptions (
user_id,
subscription_id,
plan_code,
billing_cycle,
start_date,
end_date,
status,
is_current,
paid_amount,
original_price,
subscription_type
)
SELECT
id AS user_id,
CONCAT('FREE_', id, '_', UNIX_TIMESTAMP(NOW())),
'free',
'monthly',
NOW(),
'2099-12-31 23:59:59', -- 免费版永久有效
'active',
TRUE,
0,
0,
'new'
FROM user
WHERE id NOT IN (
SELECT DISTINCT user_id FROM user_subscriptions WHERE plan_code IN ('pro', 'max')
);
*/
-- ============================================
-- 第七步: 验证数据完整性
-- ============================================
-- 检查套餐数据
SELECT * FROM subscription_plans;
-- 检查用户订阅数据
SELECT
plan_code,
COUNT(*) as user_count,
SUM(CASE WHEN is_current = TRUE THEN 1 ELSE 0 END) as current_count
FROM user_subscriptions
GROUP BY plan_code;
-- 检查支付订单数据
SELECT
status,
COUNT(*) as order_count,
SUM(final_amount) as total_amount
FROM payment_orders
GROUP BY status;
-- ============================================
-- 第八步: 添加外键约束(可选)
-- ============================================
-- 注意: 只有在确认 users 表存在且数据完整时才执行
-- ALTER TABLE user_subscriptions
-- ADD CONSTRAINT fk_user_subscriptions_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE payment_orders
-- ADD CONSTRAINT fk_payment_orders_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE payment_orders
-- ADD CONSTRAINT fk_payment_orders_promo
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_promo
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_order
-- FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE;
-- ============================================
-- 第九步: 创建测试数据(开发环境)
-- ============================================
-- 插入测试优惠码
INSERT INTO promo_codes (
code,
description,
discount_type,
discount_value,
applicable_plans,
applicable_cycles,
max_total_uses,
max_uses_per_user,
valid_from,
valid_until,
is_active
) VALUES
(
'WELCOME2025',
'2025新用户专享',
'percentage',
20.00,
NULL, -- 适用所有套餐
NULL, -- 适用所有周期
1000,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 90 DAY),
TRUE
),
(
'YEAR2025',
'年付专享',
'percentage',
10.00,
NULL,
JSON_ARRAY('yearly'), -- 仅适用年付
500,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 365 DAY),
TRUE
),
(
'TESTCODE',
'测试优惠码 - 固定减100元',
'fixed_amount',
100.00,
NULL,
NULL,
100,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 30 DAY),
TRUE
);
-- ============================================
-- 迁移完成提示
-- ============================================
SELECT '===================================' AS '';
SELECT '数据库迁移完成!' AS '状态';
SELECT '===================================' AS '';
SELECT '请检查以下数据:' AS '提示';
SELECT '1. subscription_plans 表是否有2条记录 (pro, max)' AS '';
SELECT '2. user_subscriptions 表数据是否正确' AS '';
SELECT '3. payment_orders 表结构是否正确' AS '';
SELECT '4. 备份表 (*_backup) 已创建' AS '';
SELECT '===================================' AS '';
SELECT '下一步: 更新后端代码 (app.py, models.py)' AS '';
SELECT '===================================' AS '';

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

339
index.pug Normal file
View File

@@ -0,0 +1,339 @@
extends layouts/layout
block content
+header(true, false, false)
<div class="overflow-hidden">
// hero
<div class="relative pt-58 pb-20 max-xl:pt-48 max-lg:pt-44 max-md:pt-21 max-md:pb-15">
<div class="center relative z-3" data-aos="fade">
<div class="max-w-187">
<div class="inline-flex items-center gap-2 mb-6 px-4 py-2 rounded-full bg-gradient-to-r from-green/20 to-green/5 border border-green/30 backdrop-blur-sm max-md:mb-3">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0L9.798 5.579L15.708 4.292L11.854 8.854L15.708 13.416L9.798 12.129L8 18L6.202 12.421L0.292 13.708L4.146 9.146L0.292 4.584L6.202 5.871L8 0Z"/>
</svg>
<span class="text-title-5 text-green max-md:text-[14px]">金融AI技术领航者</span>
</div>
<div class="mb-8 text-big-title-1 bg-radial-white-1 bg-clip-text text-transparent max-xl:text-big-title-2 max-lg:text-title-1 max-lg:mb-10 max-md:mb-6 max-md:text-big-title-mobile">智能舆情分析系统</div>
<div class="flex flex-wrap gap-3 mb-8 max-lg:mb-6 max-md:mb-4">
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm0 14c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6zm3.5-6c0 1.9-1.6 3.5-3.5 3.5S4.5 9.9 4.5 8 6.1 4.5 8 4.5 11.5 6.1 11.5 8z"/>
</svg>
<span class="text-title-5 text-white/90 max-md:text-[13px]">深度数据挖掘</span>
</div>
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13.5 2h-11C1.7 2 1 2.7 1 3.5v9c0 .8.7 1.5 1.5 1.5h11c.8 0 1.5-.7 1.5-1.5v-9c0-.8-.7-1.5-1.5-1.5zM8 11.5c-1.9 0-3.5-1.6-3.5-3.5S6.1 4.5 8 4.5s3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5z"/>
</svg>
<span class="text-title-5 text-white/90 max-md:text-[13px]">7×24小时监控</span>
</div>
</div>
<div class="max-w-94 mb-9.5 text-description max-lg:max-w-76 max-md:max-w-full max-md:mb-3.5">基于金融领域微调的大语言模型7×24小时不间断对舆情数据进行深度挖掘和分析对历史事件进行复盘关联相关标的为投资决策提供前瞻性的智能洞察。</div>
<div class="flex gap-7.5 max-md:mb-12.5">
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="wechat-app.jpg" title="微信小程序">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.889 8.333c.31 0 .611.028.903.078C14.292 5.31 11.403 3 7.917 3 4.083 3 1 5.686 1 9.028c0 1.944 1.028 3.639 2.639 4.861L3 16.111l2.5-1.25c.833.194 1.528.333 2.417.333.278 0 .556-.014.833-.042-.278-.805-.417-1.652-.417-2.513 0-3.264 2.764-5.903 6.556-5.903v-.403zM10.139 6.528c.583 0 1.055.472 1.055 1.055s-.472 1.055-1.055 1.055-1.055-.472-1.055-1.055.472-1.055 1.055-1.055zM5.694 8.639c-.583 0-1.055-.472-1.055-1.055s.472-1.055 1.055-1.055 1.055.472 1.055 1.055-.472 1.055-1.055 1.055zm8.195 1.694c-2.847 0-5.139 2.014-5.139 4.486 0 2.472 2.292 4.486 5.139 4.486.764 0 1.528-.139 2.222-.347L18.333 20l-.625-1.875c1.25-.972 2.014-2.361 2.014-3.958 0-2.472-2.292-4.486-5.139-4.486h-.694zm-2.084 3.125c.389 0 .695.306.695.694s-.306.695-.695.695-.694-.306-.694-.695.305-.694.694-.694zm4.167 0c.389 0 .694.306.694.694s-.305.695-.694.695-.695-.306-.695-.695.306-.694.695-.694z"/>
</svg>
</a>
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="public.jpg" title="微信公众号">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm3.889 6.944c.139 0 .278.014.417.028-1.306-2.958-4.723-5.027-8.611-5.027C2.611 1.945 0 4.306 0 7.222c0 1.528.806 2.861 2.083 3.819l-.417 1.945 1.945-.972c.639.139 1.167.25 1.861.25.222 0 .444-.014.667-.028-.222-.639-.333-1.306-.333-1.986 0-2.569 2.181-4.653 5.139-4.653l.944-.653zm-5.278-2.5c.458 0 .833.375.833.833s-.375.833-.833.833-.833-.375-.833-.833.375-.833.833-.833zM4.167 6.111c-.458 0-.833-.375-.833-.833s.375-.833.833-.833.833.375.833.833-.375.833-.833.833zm9.722 3.333c-2.236 0-4.028 1.583-4.028 3.528s1.792 3.528 4.028 3.528c.597 0 1.194-.111 1.736-.278l1.542.694-.486-1.472c.972-.764 1.597-1.861 1.597-3.125 0-1.945-1.792-3.528-4.028-3.528h-.361zm-1.667 2.5c.306 0 .556.25.556.556s-.25.556-.556.556-.556-.25-.556-.556.25-.556.556-.556zm3.334 0c.305 0 .555.25.555.556s-.25.556-.555.556-.556-.25-.556-.556.25-.556.556-.556z"/>
</svg>
</a>
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="customer-service.jpg" title="微信客服号">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm4.861 7.222c.167 0 .333.014.5.028C14.097 4.444 11.139 2 7.5 2 3.889 2 1 4.444 1 7.5c0 1.778.972 3.333 2.5 4.444l-.5 2.223 2.222-1.111c.722.167 1.333.278 2.111.278.278 0 .556-.014.834-.028-.278-.722-.417-1.5-.417-2.306 0-2.972 2.5-5.389 5.833-5.389l1.278-.389zm-6.028-2.777c.528 0 .945.417.945.945s-.417.944-.945.944-.944-.416-.944-.944.416-.945.944-.945zm-4.166 1.888c-.528 0-.945-.416-.945-.944s.417-.945.945-.945.944.417.944.945-.416.944-.944.944zm10.277 3.611c-2.569 0-4.611 1.806-4.611 4.028s2.042 4.028 4.611 4.028c.694 0 1.389-.125 2-.306L19 18.889l-.556-1.667c1.111-.889 1.833-2.139 1.833-3.611 0-2.222-2.042-4.028-4.611-4.028h-.722zm-1.944 2.778c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zm3.889 0c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zM10 14.444c0 .306-.25.556-.556.556H6.111c-.306 0-.556-.25-.556-.556s.25-.555.556-.555h3.333c.306 0 .556.25.556.555z"/>
</svg>
</a>
</div>
<div class="absolute right-20 bottom-0 flex gap-4 max-xl:right-10 max-md:static">
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
<div class="absolute inset-0 border border-line rounded-lg"></div>
img(class="w-5" src=require('Images/clock.svg') alt="")
</div>
<div class="text-title-4 max-md:text-title-3-mobile">实时数据分析</div>
</div>
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
<div class="absolute inset-0 border border-line rounded-lg"></div>
img(class="w-5" src=require('Images/floor.svg') alt="")
</div>
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理</div>
</div>
</div>
</div>
</div>
<div class="absolute top-23 right-[calc(50%-28.5rem)] size-178 rounded-full max-xl:size-140 max-md:top-36 max-md:right-auto max-md:left-8.5 max-md:size-133">
<div class="absolute -inset-[10%] mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
</div>
<div class="absolute inset-0 rounded-full shadow-[0.875rem_1.0625rem_1.25rem_0_rgba(255,255,255,0.25)_inset] bg-black/1"></div>
</div>
<div>
<div class="absolute top-61.5 right-[calc(50%-35.18rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-md:top-36 max-lg:-right-96 max-md:left-74 max-md:right-auto"></div>
<div class="absolute top-77 left-[calc(50%-57.5rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-lg:-left-60 max-md:top-84 max-md:-left-52 max-md:size-80"></div>
</div>
</div>
// details
<div class="pt-40.5 pb-30.5 max-xl:pt-30 max-lg:py-24 max-md:py-15">
<div class="center">
<div class="flex flex-wrap -mt-4 -mx-2">
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">99%</div>
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">金融数据理解准确率</div>
<div class="mt-2.5 text-description max-md:mt-2">基于金融领域深度微调的大语言模型,精准理解市场动态和舆情变化。</div>
</div>
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-1.png') alt="")
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">24/7</div>
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">全天候舆情监控</div>
<div class="mt-2.5 text-description max-md:mt-2">7×24小时不间断监控市场舆情第一时间捕捉关键信息。</div>
</div>
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-2.png') alt="")
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
<div class="absolute top-0 left-0 right-0 flex justify-center">
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-3.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="mb-2.5 text-title-4 max-md:mb-1.5 max-md:text-title-3-mobile">深度模型微调</div>
<div class="text-description">针对金融领域数据进行专业化模型训练和优化。</div>
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end grow mt-4 mx-2 px-8.5 pb-7 overflow-hidden max-xl:px-6 max-lg:order-5" data-aos="fade">
<div class="absolute top-0 left-0 flex justify-center max-2xl:top-8 max-lg:top-0 max-md:-left-3 max-md:w-176">
img(class="w-full" src=require('Images/details-pic-4.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="flex items-center gap-3 mb-3">
<div class="relative flex justify-center items-center shrink-0 w-12.5 h-12.5 rounded-lg bg-gradient-to-b from-[#F4D03F] to-[#D4AF37] shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset] after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none">
img(class="w-4" src=require('Images/lightning.svg') alt="")
</div>
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-2 leading-tight max-xl:text-title-2 max-md:text-title-1-mobile">&lt;100ms</div>
</div>
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理系统</div>
<div class="mt-2.5 text-description max-md:mt-2">毫秒级响应速度,实时处理海量舆情数据。</div>
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
<div class="absolute top-0 left-0 right-0 flex justify-center">
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-5.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:text-title-1-mobile">历史复盘</div>
<div class="text-description">对历史事件进行深度复盘分析,关联标的,辅助投资决策。</div>
</div>
</div>
</div>
</div>
</div>
// features
<div class="relative pt-34.5 pb-41 max-xl:pt-20 max-xl:pb-30 max-lg:py-24 max-md:pt-15 max-md:pb-14">
<div class="center relative z-2">
<div class="max-w-148 mx-auto mb-18 text-center max-xl:mb-14 max-md:mb-8.5" data-aos="fade">
<div class="label mb-3 max-md:mb-1">核心功能</div>
<div class="mb-6 bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:mb-3 max-md:text-title-1-mobile">我们能做什么?</div>
<div class="text-description">基于AI的舆情分析系统深度挖掘市场动态为投资决策提供实时智能洞察。</div>
</div>
<div class="flex flex-wrap -mt-4 -mx-2">
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-1.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">舆情数据挖掘</div>
<div class="text-description">实时采集和分析全网金融舆情,捕捉市场情绪变化。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-2.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">智能事件关联</div>
<div class="text-description">自动关联相关标的和历史事件,构建完整的信息图谱。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-3.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">历史复盘</div>
<div class="text-description">深度复盘历史事件走势,洞察关键节点与转折,为投资决策提供经验参考。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-4.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">专精金融的AI聊天</div>
<div class="text-description">基于金融领域深度训练的智能对话助手,即时解答市场问题,提供专业投资建议。</div>
</div>
</div>
</div>
</div>
<div class="max-md:hidden">
<div class="absolute top-47.5 left-[calc(50%-52.38rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
<div class="absolute bottom-2.5 right-[calc(50%-51.44rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
</div>
</div>
// pricing
<div class="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15" id="pricing">
<div class="center">
<div class="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8" data-aos="fade">
<div class="label mb-3 max-md:mb-1.5">订阅方案</div>
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">立即开启智能决策</div>
</div>
<div class="flex justify-center gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5" data-aos="fade">
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84">
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 text-white">PRO</div>
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[1.25rem] bg-white/1 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-white/2 backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
<div class="flex items-end gap-3 mb-4">
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">¥198</div>
<div class="text-title-5">/月</div>
</div>
<a class="btn btn-secondary w-full bg-line !text-description hover:!text-white" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Pro版</a>
</div>
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>事件关联股票深度分析</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>历史事件智能对比复盘</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>事件概念关联与挖掘</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>概念板块个股追踪</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>概念深度研报与解读</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>个股异动实时预警</div>
</div>
</div>
</div>
</div>
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-gold/15 before:rounded-full before:blur-[3.375rem] after:absolute after:inset-0 after:border after:border-gold/30 after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84">
<div class="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
</div>
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 bg-gradient-to-r from-gold-dark/20 to-gold/20 rounded-t-[1.25rem] text-gold">MAX</div>
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[2rem] shadow-2 bg-white/7 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-line backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
<div class="flex items-end gap-3 mb-4">
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">¥998</div>
<div class="text-title-5">/月</div>
</div>
<a class="btn btn-primary w-full" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Max版</a>
</div>
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-gold rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div class="font-medium">包含Pro版全部功能</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>事件传导链路智能分析</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>概念演变时间轴追溯</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>个股全方位深度研究</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>价小前投研助手无限使用</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>新功能优先体验权</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
<div>专属客服一对一服务</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
include includes/start
</div>
+footer(true)

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();

631
new_subscription_logic.py Normal file
View File

@@ -0,0 +1,631 @@
# -*- coding: utf-8 -*-
"""
新版订阅支付系统核心逻辑
版本: v2.0.0
日期: 2025-11-19
核心改进:
1. 续费价格与新购价格完全一致
2. 不再计算剩余价值折算
3. 逻辑简化,易于维护
"""
from datetime import datetime, timedelta
from decimal import Decimal
import json
import random
# ============================================
# 辅助函数
# ============================================
def beijing_now():
"""获取北京时间"""
from datetime import timezone, timedelta
utc_now = datetime.now(timezone.utc)
beijing_time = utc_now.astimezone(timezone(timedelta(hours=8)))
return beijing_time.replace(tzinfo=None)
def generate_order_no(user_id):
"""生成订单号"""
timestamp = int(beijing_now().timestamp() * 1000000)
random_suffix = random.randint(1000, 9999)
return f"{timestamp}{user_id:04d}{random_suffix}"
def generate_subscription_id():
"""生成订阅ID"""
timestamp = int(beijing_now().timestamp() * 1000)
random_suffix = random.randint(10000, 99999)
return f"SUB_{timestamp}_{random_suffix}"
# ============================================
# 核心业务逻辑
# ============================================
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None, user_id=None, db_session=None):
"""
计算订阅价格(新购和续费价格完全一致)
Args:
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
user_id: 用户ID可选,用于优惠码验证)
db_session: 数据库会话(可选)
Returns:
dict: {
'success': True/False,
'plan_code': 'pro',
'plan_name': 'Pro 专业版',
'billing_cycle': 'yearly',
'original_price': 2699.00,
'discount_amount': 0,
'final_amount': 2699.00,
'promo_code': None,
'promo_error': None,
'error': None # 如果有错误
}
"""
from models import SubscriptionPlan, PromoCode # 需要在实际使用时导入
try:
# 1. 查询套餐
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
if not plan:
return {
'success': False,
'error': f'套餐 {plan_code} 不存在或已下架'
}
# 2. 获取对应周期的价格
price_field_map = {
'monthly': 'price_monthly',
'quarterly': 'price_quarterly',
'semiannual': 'price_semiannual',
'yearly': 'price_yearly'
}
price_field = price_field_map.get(billing_cycle)
if not price_field:
return {
'success': False,
'error': f'不支持的计费周期: {billing_cycle}'
}
original_price = getattr(plan, price_field, None)
if original_price is None or original_price <= 0:
return {
'success': False,
'error': f'{billing_cycle} 周期价格未配置'
}
original_price = float(original_price)
# 3. 构建基础结果
result = {
'success': True,
'plan_code': plan_code,
'plan_name': plan.plan_name,
'billing_cycle': billing_cycle,
'original_price': original_price,
'discount_amount': 0.0,
'final_amount': original_price,
'promo_code': None,
'promo_error': None,
'error': None
}
# 4. 应用优惠码(如果有)
if promo_code and promo_code.strip():
promo_code = promo_code.strip().upper()
# 验证优惠码
promo, error = validate_promo_code(
promo_code,
plan_code,
billing_cycle,
original_price,
user_id,
db_session
)
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
except Exception as e:
return {
'success': False,
'error': f'价格计算失败: {str(e)}'
}
def get_current_subscription(user_id, db_session=None):
"""
获取用户当前生效的订阅
Args:
user_id: 用户ID
db_session: 数据库会话(可选)
Returns:
UserSubscription 对象 或 None
"""
from models import UserSubscription
try:
subscription = UserSubscription.query.filter_by(
user_id=user_id,
is_current=True
).first()
# 检查是否过期
if subscription and subscription.end_date < beijing_now():
subscription.status = 'expired'
subscription.is_current = False
if db_session:
db_session.commit()
return None
return subscription
except Exception as e:
print(f"获取当前订阅失败: {e}")
return None
def determine_subscription_type(user_id, plan_code, billing_cycle):
"""
判断订阅类型(新购还是续费)
Args:
user_id: 用户ID
plan_code: 目标套餐代码
billing_cycle: 目标计费周期
Returns:
str: 'new''renew'
"""
current_sub = get_current_subscription(user_id)
# 如果没有订阅或订阅是免费版,则为新购
if not current_sub or current_sub.plan_code == 'free':
return 'new'
# 如果是付费订阅,则为续费
if current_sub.plan_code in ['pro', 'max']:
return 'renew'
return 'new'
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None, db_session=None):
"""
创建订阅支付订单
Args:
user_id: 用户ID
plan_code: 套餐代码
billing_cycle: 计费周期
promo_code: 优惠码(可选)
db_session: 数据库会话
Returns:
dict: {
'success': True/False,
'order': PaymentOrder 对象,
'error': None
}
"""
from models import PaymentOrder
try:
# 1. 计算价格
price_result = calculate_subscription_price(
plan_code,
billing_cycle,
promo_code,
user_id,
db_session
)
if not price_result.get('success'):
return {
'success': False,
'error': price_result.get('error', '价格计算失败')
}
# 2. 判断订阅类型
subscription_type = determine_subscription_type(user_id, plan_code, billing_cycle)
# 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=Decimal(str(price_result['original_price'])),
discount_amount=Decimal(str(price_result['discount_amount'])),
final_amount=Decimal(str(price_result['final_amount'])),
promo_code=promo_code,
status='pending',
expired_at=beijing_now() + timedelta(minutes=30)
)
if db_session:
db_session.add(order)
db_session.commit()
return {
'success': True,
'order': order,
'subscription_type': subscription_type,
'error': None
}
except Exception as e:
if db_session:
db_session.rollback()
return {
'success': False,
'error': f'创建订单失败: {str(e)}'
}
def activate_subscription_after_payment(order_id, db_session=None):
"""
支付成功后激活订阅
Args:
order_id: 订单ID
db_session: 数据库会话
Returns:
dict: {
'success': True/False,
'subscription': UserSubscription 对象,
'error': None
}
"""
from models import PaymentOrder, UserSubscription, PromoCodeUsage
try:
# 1. 查询订单
order = PaymentOrder.query.get(order_id)
if not order:
return {'success': False, 'error': '订单不存在'}
if order.status != 'paid':
return {'success': False, 'error': '订单未支付'}
# 2. 检查是否已经激活
existing_sub = UserSubscription.query.filter_by(
payment_order_id=order.id
).first()
if existing_sub:
return {
'success': True,
'subscription': existing_sub,
'message': '订阅已激活'
}
# 3. 计算订阅周期天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
days = cycle_days_map.get(order.billing_cycle, 30)
# 4. 获取当前订阅
current_sub = get_current_subscription(order.user_id, db_session)
# 5. 计算开始和结束时间
now = beijing_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)
# 6. 创建新订阅记录
new_subscription = UserSubscription(
user_id=order.user_id,
subscription_id=generate_subscription_id(),
plan_code=order.plan_code,
billing_cycle=order.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,
auto_renew=False
)
# 7. 将旧订阅标记为非当前
if current_sub:
current_sub.is_current = False
if db_session:
db_session.add(new_subscription)
# 8. 记录优惠码使用
if order.promo_code_id:
usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
discount_amount=order.discount_amount
)
db_session.add(usage)
# 更新优惠码使用次数
from models import PromoCode
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses += 1
db_session.commit()
return {
'success': True,
'subscription': new_subscription,
'error': None
}
except Exception as e:
if db_session:
db_session.rollback()
return {
'success': False,
'error': f'激活订阅失败: {str(e)}'
}
def get_subscription_button_text(user_id, plan_code, billing_cycle):
"""
获取订阅按钮文字
Args:
user_id: 用户ID
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期
Returns:
str: 按钮文字
"""
from models import SubscriptionPlan
# 获取套餐显示名称
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code).first()
plan_name = plan.plan_name if plan else plan_code.upper()
# 获取周期显示名称
cycle_names = {
'monthly': '月付',
'quarterly': '季付',
'semiannual': '半年付',
'yearly': '年付'
}
cycle_name = cycle_names.get(billing_cycle, billing_cycle)
# 获取当前订阅
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"选择 {plan_name}"
# 2. 如果是当前套餐且周期相同
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
return f"续费 {plan_name}"
# 3. 如果是当前套餐但周期不同
if current_sub.plan_code == plan_code:
return f"切换至{cycle_name}"
# 4. 如果是不同套餐
return f"选择 {plan_name}"
# ============================================
# 优惠码相关函数
# ============================================
def validate_promo_code(code, plan_code, billing_cycle, amount, user_id=None, db_session=None):
"""
验证优惠码
Args:
code: 优惠码
plan_code: 套餐代码
billing_cycle: 计费周期
amount: 订单金额
user_id: 用户ID可选
db_session: 数据库会话(可选)
Returns:
tuple: (PromoCode对象 或 None, 错误信息 或 None)
"""
from models import PromoCode, PromoCodeUsage
try:
# 查询优惠码
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
if not promo:
return None, "优惠码不存在或已失效"
# 检查有效期
now = beijing_now()
if promo.valid_from and now < promo.valid_from:
return None, "优惠码尚未生效"
if promo.valid_until and now > promo.valid_until:
return None, "优惠码已过期"
# 检查总使用次数
if promo.max_total_uses and promo.current_uses >= promo.max_total_uses:
return None, "优惠码使用次数已达上限"
# 检查每用户使用次数
if user_id and promo.max_uses_per_user:
user_usage_count = PromoCodeUsage.query.filter_by(
promo_code_id=promo.id,
user_id=user_id
).count()
if user_usage_count >= promo.max_uses_per_user:
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
# 检查适用套餐
if promo.applicable_plans:
try:
applicable = json.loads(promo.applicable_plans)
if isinstance(applicable, list) and plan_code not in applicable:
return None, "该优惠码不适用于此套餐"
except:
pass
# 检查适用周期
if promo.applicable_cycles:
try:
applicable = json.loads(promo.applicable_cycles)
if isinstance(applicable, list) and billing_cycle not in applicable:
return None, "该优惠码不适用于此计费周期"
except:
pass
# 检查最低消费
if promo.min_amount and amount < float(promo.min_amount):
return None, f"需满 ¥{float(promo.min_amount):.2f} 才可使用此优惠码"
return promo, None
except Exception as e:
return None, f"验证优惠码时出错: {str(e)}"
def calculate_discount(promo_code, amount):
"""
计算优惠金额
Args:
promo_code: PromoCode 对象
amount: 订单金额
Returns:
Decimal: 优惠金额
"""
try:
if promo_code.discount_type == 'percentage':
# 百分比折扣
discount = Decimal(str(amount)) * Decimal(str(promo_code.discount_value)) / Decimal('100')
elif promo_code.discount_type == 'fixed_amount':
# 固定金额折扣
discount = Decimal(str(promo_code.discount_value))
else:
discount = Decimal('0')
# 确保折扣不超过总金额
discount = min(discount, Decimal(str(amount)))
return discount
except Exception as e:
print(f"计算折扣失败: {e}")
return Decimal('0')
# ============================================
# 辅助查询函数
# ============================================
def get_user_subscription_history(user_id, limit=10):
"""
获取用户订阅历史
Args:
user_id: 用户ID
limit: 返回记录数量限制
Returns:
list: UserSubscription 对象列表
"""
from models import UserSubscription
try:
subscriptions = UserSubscription.query.filter_by(
user_id=user_id
).order_by(
UserSubscription.created_at.desc()
).limit(limit).all()
return subscriptions
except Exception as e:
print(f"获取订阅历史失败: {e}")
return []
def check_subscription_status(user_id):
"""
检查用户订阅状态
Args:
user_id: 用户ID
Returns:
dict: {
'has_subscription': True/False,
'plan_code': 'pro''max''free',
'status': 'active''expired',
'end_date': datetime 或 None,
'days_left': int
}
"""
current_sub = get_current_subscription(user_id)
if not current_sub or current_sub.plan_code == 'free':
return {
'has_subscription': False,
'plan_code': 'free',
'status': 'active',
'end_date': None,
'days_left': 999
}
now = beijing_now()
days_left = (current_sub.end_date - now).days if current_sub.end_date > now else 0
return {
'has_subscription': True,
'plan_code': current_sub.plan_code,
'status': current_sub.status,
'end_date': current_sub.end_date,
'days_left': days_left
}

669
new_subscription_routes.py Normal file
View File

@@ -0,0 +1,669 @@
# -*- coding: utf-8 -*-
"""
新版订阅支付系统 API 路由
版本: v2.0.0
日期: 2025-11-19
使用方法:
将这些路由添加到你的 Flask app.py 中
"""
from flask import jsonify, request, session
from new_subscription_logic import (
calculate_subscription_price,
create_subscription_order,
activate_subscription_after_payment,
get_subscription_button_text,
get_current_subscription,
check_subscription_status,
get_user_subscription_history
)
# ============================================
# API 路由定义
# ============================================
@app.route('/api/v2/subscription/plans', methods=['GET'])
def get_subscription_plans_v2():
"""
获取订阅套餐列表(新版)
Response:
{
"success": true,
"data": [
{
"plan_code": "pro",
"plan_name": "Pro 专业版",
"description": "为专业投资者打造",
"prices": {
"monthly": 299.00,
"quarterly": 799.00,
"semiannual": 1499.00,
"yearly": 2699.00
},
"features": [...],
"is_active": true
},
...
]
}
"""
try:
from models import SubscriptionPlan
plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(
SubscriptionPlan.display_order
).all()
data = []
for plan in plans:
data.append({
'plan_code': plan.plan_code,
'plan_name': plan.plan_name,
'description': plan.description,
'prices': {
'monthly': float(plan.price_monthly),
'quarterly': float(plan.price_quarterly),
'semiannual': float(plan.price_semiannual),
'yearly': float(plan.price_yearly)
},
'features': json.loads(plan.features) if plan.features else [],
'is_active': plan.is_active,
'display_order': plan.display_order
})
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取套餐列表失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/calculate-price', methods=['POST'])
def calculate_price_v2():
"""
计算订阅价格(新版 - 新购和续费价格一致)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"plan_code": "pro",
"plan_name": "Pro 专业版",
"billing_cycle": "yearly",
"original_price": 2699.00,
"discount_amount": 539.80,
"final_amount": 2159.20,
"promo_code": "WELCOME2025",
"promo_error": null
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
# 计算价格
result = calculate_subscription_price(
plan_code=plan_code,
billing_cycle=billing_cycle,
promo_code=promo_code,
user_id=session['user_id'],
db_session=db.session
)
if not result.get('success'):
return jsonify(result), 400
return jsonify({
'success': True,
'data': result
})
except Exception as e:
return jsonify({
'success': False,
'error': f'计算价格失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/create-order', methods=['POST'])
def create_order_v2():
"""
创建支付订单(新版)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"order_no": "1732012345678901231234",
"plan_code": "pro",
"billing_cycle": "yearly",
"subscription_type": "renew", // 或 "new"
"original_price": 2699.00,
"discount_amount": 539.80,
"final_amount": 2159.20,
"qr_code_url": "https://...",
"status": "pending",
"expired_at": "2025-11-19T15:30:00",
...
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
# 创建订单
order_result = create_subscription_order(
user_id=session['user_id'],
plan_code=plan_code,
billing_cycle=billing_cycle,
promo_code=promo_code,
db_session=db.session
)
if not order_result.get('success'):
return jsonify({
'success': False,
'error': order_result.get('error')
}), 400
order = order_result['order']
# 生成微信支付二维码
try:
from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready
is_ready, ready_msg = check_wechat_pay_ready()
if not is_ready:
# 使用模拟二维码
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"演示模式 - {ready_msg}"
else:
wechat_pay = create_wechat_pay_instance()
# 创建微信支付订单
plan_display = f"{plan_code.upper()}-{billing_cycle}"
wechat_result = wechat_pay.create_native_order(
order_no=order.order_no,
total_fee=float(order.final_amount),
body=f"VFr-{plan_display}",
product_id=f"{plan_code}_{billing_cycle}"
)
if wechat_result['success']:
wechat_code_url = wechat_result['code_url']
import urllib.parse
encoded_url = urllib.parse.quote(wechat_code_url, safe='')
qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}"
order.qr_code_url = qr_image_url
order.prepay_id = wechat_result.get('prepay_id')
order.remark = f"微信支付 - {wechat_code_url}"
else:
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"微信支付失败: {wechat_result.get('error')}"
except Exception as e:
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"支付异常: {str(e)}"
db.session.commit()
return jsonify({
'success': True,
'data': {
'id': order.id,
'order_no': order.order_no,
'plan_code': order.plan_code,
'billing_cycle': order.billing_cycle,
'subscription_type': order.subscription_type,
'original_price': float(order.original_price),
'discount_amount': float(order.discount_amount),
'final_amount': float(order.final_amount),
'promo_code': order.promo_code,
'qr_code_url': order.qr_code_url,
'status': order.status,
'expired_at': order.expired_at.isoformat() if order.expired_at else None,
'created_at': order.created_at.isoformat() if order.created_at else None
},
'message': '订单创建成功'
})
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': f'创建订单失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/order/<int:order_id>/status', methods=['GET'])
def check_order_status_v2(order_id):
"""
查询订单支付状态(新版)
Response:
{
"success": true,
"payment_success": true, // 是否支付成功
"data": {
"order_no": "...",
"status": "paid",
...
},
"message": "支付成功!订阅已激活"
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import PaymentOrder
order = PaymentOrder.query.filter_by(
id=order_id,
user_id=session['user_id']
).first()
if not order:
return jsonify({'success': False, 'error': '订单不存在'}), 404
# 如果订单已经是已支付状态
if order.status == 'paid':
return jsonify({
'success': True,
'payment_success': True,
'data': {
'order_no': order.order_no,
'status': order.status,
'final_amount': float(order.final_amount)
},
'message': '订单已支付'
})
# 如果订单过期
if order.is_expired():
order.status = 'expired'
db.session.commit()
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'expired'},
'message': '订单已过期'
})
# 调用微信支付API查询状态
try:
from wechat_pay import create_wechat_pay_instance
wechat_pay = create_wechat_pay_instance()
query_result = wechat_pay.query_order(order_no=order.order_no)
if query_result['success']:
trade_state = query_result.get('trade_state')
transaction_id = query_result.get('transaction_id')
if trade_state == 'SUCCESS':
# 支付成功
order.mark_as_paid(transaction_id)
db.session.commit()
# 激活订阅
activate_result = activate_subscription_after_payment(
order.id,
db_session=db.session
)
if activate_result.get('success'):
return jsonify({
'success': True,
'payment_success': True,
'data': {
'order_no': order.order_no,
'status': 'paid'
},
'message': '支付成功!订阅已激活'
})
else:
return jsonify({
'success': True,
'payment_success': True,
'data': {'status': 'paid'},
'message': '支付成功,但激活失败,请联系客服'
})
elif trade_state in ['NOTPAY', 'USERPAYING']:
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'pending'},
'message': '等待支付...'
})
else:
order.status = 'cancelled'
db.session.commit()
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'cancelled'},
'message': '支付已取消'
})
except Exception as e:
# 查询失败,返回当前状态
pass
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': order.status},
'message': '无法查询支付状态,请稍后重试'
})
except Exception as e:
return jsonify({
'success': False,
'error': f'查询失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/order/<int:order_id>/force-update', methods=['POST'])
def force_update_status_v2(order_id):
"""
强制更新订单支付状态(新版)
用于支付完成但页面未更新的情况
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import PaymentOrder
order = PaymentOrder.query.filter_by(
id=order_id,
user_id=session['user_id']
).first()
if not order:
return jsonify({'success': False, 'error': '订单不存在'}), 404
# 检查微信支付状态
try:
from wechat_pay import create_wechat_pay_instance
wechat_pay = create_wechat_pay_instance()
query_result = wechat_pay.query_order(order_no=order.order_no)
if query_result['success'] and query_result.get('trade_state') == 'SUCCESS':
transaction_id = query_result.get('transaction_id')
# 标记订单为已支付
order.mark_as_paid(transaction_id)
db.session.commit()
# 激活订阅
activate_result = activate_subscription_after_payment(
order.id,
db_session=db.session
)
if activate_result.get('success'):
return jsonify({
'success': True,
'payment_success': True,
'message': '状态更新成功!订阅已激活'
})
else:
return jsonify({
'success': True,
'payment_success': True,
'message': '支付成功,但激活失败,请联系客服',
'error': activate_result.get('error')
})
else:
return jsonify({
'success': True,
'payment_success': False,
'message': '微信支付状态未更新,请继续等待'
})
except Exception as e:
return jsonify({
'success': False,
'error': f'查询微信支付状态失败: {str(e)}'
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f'强制更新失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/current', methods=['GET'])
def get_current_subscription_v2():
"""
获取当前用户订阅信息(新版)
Response:
{
"success": true,
"data": {
"subscription_id": "SUB_1732012345_12345",
"plan_code": "pro",
"plan_name": "Pro 专业版",
"billing_cycle": "yearly",
"status": "active",
"start_date": "2025-11-19T00:00:00",
"end_date": "2026-11-19T00:00:00",
"days_left": 365,
"auto_renew": false
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import SubscriptionPlan
subscription = get_current_subscription(session['user_id'])
if not subscription:
return jsonify({
'success': True,
'data': {
'plan_code': 'free',
'plan_name': '免费版',
'status': 'active'
}
})
# 获取套餐名称
plan = SubscriptionPlan.query.filter_by(plan_code=subscription.plan_code).first()
plan_name = plan.plan_name if plan else subscription.plan_code.upper()
# 计算剩余天数
from datetime import datetime
now = datetime.now()
days_left = (subscription.end_date - now).days if subscription.end_date > now else 0
return jsonify({
'success': True,
'data': {
'subscription_id': subscription.subscription_id,
'plan_code': subscription.plan_code,
'plan_name': plan_name,
'billing_cycle': subscription.billing_cycle,
'status': subscription.status,
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
'days_left': days_left,
'auto_renew': subscription.auto_renew
}
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取订阅信息失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/history', methods=['GET'])
def get_subscription_history_v2():
"""
获取用户订阅历史(新版)
Query Params:
limit: 返回记录数量默认10
Response:
{
"success": true,
"data": [
{
"subscription_id": "SUB_...",
"plan_code": "pro",
"billing_cycle": "yearly",
"start_date": "...",
"end_date": "...",
"paid_amount": 2699.00,
"status": "expired"
},
...
]
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
limit = request.args.get('limit', 10, type=int)
subscriptions = get_user_subscription_history(session['user_id'], limit)
data = []
for sub in subscriptions:
data.append({
'subscription_id': sub.subscription_id,
'plan_code': sub.plan_code,
'billing_cycle': sub.billing_cycle,
'start_date': sub.start_date.isoformat() if sub.start_date else None,
'end_date': sub.end_date.isoformat() if sub.end_date else None,
'paid_amount': float(sub.paid_amount),
'original_price': float(sub.original_price),
'discount_amount': float(sub.discount_amount),
'status': sub.status,
'created_at': sub.created_at.isoformat() if sub.created_at else None
})
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取订阅历史失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/button-text', methods=['POST'])
def get_button_text_v2():
"""
获取订阅按钮文字(新版)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly"
}
Response:
{
"success": true,
"button_text": "续费 Pro 专业版"
}
"""
try:
if 'user_id' not in session:
return jsonify({
'success': True,
'button_text': '选择套餐'
})
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
button_text = get_subscription_button_text(
session['user_id'],
plan_code,
billing_cycle
)
return jsonify({
'success': True,
'button_text': button_text
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取按钮文字失败: {str(e)}'
}), 500

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>

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

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

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');
// 找到事件发生日期或最接近的交易日

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

@@ -33,8 +33,8 @@ export const initPostHog = () => {
posthog.init(apiKey, {
api_host: apiHost,
// Pageview tracking - manual control for better accuracy
capture_pageview: false, // We'll manually capture with custom properties
// Pageview tracking - auto-capture for DAU/MAU analytics
capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
capture_pageleave: true, // Auto-capture when user leaves page
// Session Recording Configuration
@@ -185,6 +185,30 @@ export const trackEvent = (eventName, properties = {}) => {
}
};
/**
* 异步追踪事件(不阻塞主线程)
* 使用 requestIdleCallback 在浏览器空闲时发送事件
*
* @param {string} eventName - 事件名称
* @param {object} properties - 事件属性
*/
export const trackEventAsync = (eventName, properties = {}) => {
// 浏览器支持 requestIdleCallback 时使用(推荐)
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(
() => {
trackEvent(eventName, properties);
},
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
);
} else {
// 降级方案:使用 setTimeout兼容性更好
setTimeout(() => {
trackEvent(eventName, properties);
}, 0);
}
};
/**
* Track page view
*

View File

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
/**
* InvestmentPlanningCenter Context
* 用于在日历、计划、复盘三个面板间共享数据和状态
*/
import React, { createContext, useContext, ReactNode } from 'react';
import type { PlanningContextValue } from '@/types';
/**
* Planning Data Context
* 提供投资规划数据和操作方法
*/
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
/**
* PlanningDataProvider Props
*/
interface PlanningDataProviderProps {
/** Context 值 */
value: PlanningContextValue;
/** 子组件 */
children: ReactNode;
}
/**
* PlanningDataProvider 组件
* 包裹需要访问投资规划数据的组件
*/
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
return (
<PlanningDataContext.Provider value={value}>
{children}
</PlanningDataContext.Provider>
);
};
/**
* usePlanningData Hook
* 在子组件中访问投资规划数据
*
* @throws {Error} 如果在 PlanningDataProvider 外部调用
* @returns {PlanningContextValue} Context 值
*
* @example
* ```tsx
* function CalendarPanel() {
* const { allEvents, loading, toast } = usePlanningData();
* // ...
* }
* ```
*/
export const usePlanningData = (): PlanningContextValue => {
const context = useContext(PlanningDataContext);
if (!context) {
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
}
return context;
};

View File

@@ -0,0 +1,506 @@
/**
* PlansPanel - 投资计划列表面板组件
* 显示、编辑和管理投资计划
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiTarget,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* PlansPanel 组件
* 计划列表面板,显示所有投资计划
*/
export const PlansPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选计划列表(排除系统事件)
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'plan',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('PlansPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('PlansPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiTarget} color="purple.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑计划"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除计划"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="purple.500" />
</Center>
) : plans.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiTarget} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{plans.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:布局新能源板块"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="purple"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -0,0 +1,506 @@
/**
* ReviewsPanel - 投资复盘列表面板组件
* 显示、编辑和管理投资复盘
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiFileText,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* ReviewsPanel 组件
* 复盘列表面板,显示所有投资复盘
*/
export const ReviewsPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选复盘列表(排除系统事件)
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'review',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('ReviewsPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('ReviewsPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiFileText} color="green.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑复盘"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除复盘"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="green.500" />
</Center>
) : reviews.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiFileText} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{reviews.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:本周操作复盘"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细记录您的投资复盘..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="green"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,221 @@
export const subscriptionConfig = {
plans: [
{
name: 'free',
displayName: '基础版',
description: '免费体验核心功能,7项实用工具',
icon: 'star',
price: 0,
badge: '免费',
badgeColor: 'gray',
cardBorder: 'gray',
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true, limit: 'TOP3' },
{ name: '事件传导链分析(AI)', enabled: true, limit: '有限体验' },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true, limit: '限制预览' },
{ name: '个股深度分析(AI)', enabled: true, limit: '10家/月' },
{ name: '概念中心(548大概念)', enabled: true, limit: 'TOP5' },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '事件-相关标的分析', enabled: false },
{ name: '相关概念展示', enabled: false },
{ name: '高效数据筛选工具', enabled: false },
],
},
{
name: 'pro',
displayName: 'Pro 专业版',
description: '为专业投资者打造,解锁高级分析功能',
icon: 'gem',
badge: '推荐',
badgeColor: 'gold',
cardBorder: 'gold',
highlight: false,
pricingOptions: [
{
cycleKey: 'monthly',
label: '月付',
months: 1,
price: 299,
originalPrice: null,
discountPercent: 0,
},
{
cycleKey: '3months',
label: '季付',
months: 3,
price: 799,
originalPrice: 897,
discountPercent: 11,
},
{
cycleKey: '6months',
label: '半年付',
months: 6,
price: 1499,
originalPrice: 1794,
discountPercent: 16,
},
{
cycleKey: 'yearly',
label: '年付',
months: 12,
price: 2699,
originalPrice: 3588,
discountPercent: 25,
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '50家/月' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '100天' },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '板块深度分析(AI)', enabled: false },
{ name: '概念高频更新', enabled: false },
],
},
{
name: 'max',
displayName: 'Max 旗舰版',
description: '旗舰级体验,无限制使用所有功能',
icon: 'crown',
badge: '最受欢迎',
badgeColor: 'gold',
cardBorder: 'gold',
highlight: true,
pricingOptions: [
{
cycleKey: 'monthly',
label: '月付',
months: 1,
price: 599,
originalPrice: null,
discountPercent: 0,
},
{
cycleKey: '3months',
label: '季付',
months: 3,
price: 1599,
originalPrice: 1797,
discountPercent: 11,
},
{
cycleKey: '6months',
label: '半年付',
months: 6,
price: 2999,
originalPrice: 3594,
discountPercent: 17,
},
{
cycleKey: 'yearly',
label: '年付',
months: 12,
price: 5399,
originalPrice: 7188,
discountPercent: 25,
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: '板块深度分析(AI)', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '无限制' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '无限制' },
{ name: '概念高频更新', enabled: true },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
],
},
],
faqs: [
{
question: '如何取消订阅?',
answer: '您可以随时在账户设置中取消订阅。取消后,您的订阅将在当前计费周期结束时到期,期间您仍可继续使用付费功能。取消后不会立即扣款,也不会自动续费。',
},
{
question: '支持哪些支付方式?',
answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。',
},
{
question: '升级或切换套餐时,原套餐的费用怎么办?',
answer: '当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。\n\n计算方式\n• 剩余价值 = 原套餐价格 × (剩余天数 / 总天数)\n• 实付金额 = 新套餐价格 - 剩余价值 - 优惠码折扣\n\n例如您购买了年付Pro版¥2699,使用了180天后升级到Max版¥5399/年),剩余价值约¥1350将自动抵扣,实付约¥4049。',
},
{
question: '可以在不同计费周期之间切换吗?',
answer: '可以。您可以随时更改计费周期。如果从短期切换到长期,系统会计算剩余价值并应用到新的订阅中。长期套餐(季付、半年付、年付)可享受更大的折扣优惠。',
},
{
question: '是否支持退款?',
answer: '为了保障服务质量和维护公平的商业环境,我们不支持退款。\n\n建议您在订阅前\n• 充分了解各套餐的功能差异\n• 使用免费版体验基础功能\n• 根据实际需求选择合适的计费周期\n• 如有疑问可联系客服咨询\n\n提示选择长期套餐如半年付、年付可享受更大折扣,性价比更高。',
},
{
question: 'Pro版和Max版有什么区别',
answer: 'Pro版适合个人专业用户,提供高级图表、历史数据分析等功能,有一定的使用限制。Max版则是为重度用户设计,提供无限制的数据查询、板块深度分析、概念高频更新等独家功能,并享有优先技术支持。',
},
],
};
// 主题颜色配置 - 黑金配色
export const themeColors = {
// 背景渐变
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
// 主色调
primary: {
gold: '#D4AF37', // 金色
goldLight: '#F4E3A7', // 浅金色
goldDark: '#B8941F', // 深金色
},
// 背景色
bg: {
primary: '#0a0a0a', // 主背景(纯黑)
secondary: '#1a1a1a', // 次级背景(深黑)
card: '#1e1e1e', // 卡片背景
cardHover: '#252525', // 卡片悬停
},
// 文字颜色
text: {
primary: '#ffffff', // 主文字(纯白)
secondary: '#b8b8b8', // 次级文字(灰白)
muted: '#808080', // 弱化文字(灰)
gold: '#D4AF37', // 金色文字
},
// 边框颜色
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
// 状态颜色
status: {
active: '#00ff88', // 激活(绿色)
inactive: '#ff4444', // 未激活(红色)
warning: '#ff9900', // 警告(橙色)
},
};

View File

@@ -0,0 +1,35 @@
export const pricing = [
{
title: "STARTER",
price: 99,
features: [
"1 Active Bot",
"1,000 Conversations per month",
"Web & WhatsApp Integration",
"Basic Dashboard & Chat Reports",
"Email Support",
],
},
{
title: "PRO",
price: 149,
features: [
"Up to 5 Active Bots",
"10,000 Conversations per month",
"Multi-Channel (Web, WhatsApp, IG, Telegram)",
"Custom Workflows & Automation",
"Real-Time Reports & Zapier Integration",
],
},
{
title: "ENTERPRISE",
price: 199,
features: [
"Unlimited Bots & Chats",
"Role-Based Access & Team Management",
"Integration to CRM & Custom APIs",
"Advanced AI Training (LLM/NLP)",
"Dedicated Onboarding Team",
],
},
];

129
src/views/Pricing/index.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { motion } from "framer-motion";
import Button from "@/components/Button2";
import { pricing } from "./content";
const Pricing = () => (
<div
id="pricing"
className="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15"
>
<div className="center">
<motion.div
className="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.7 }}
viewport={{ amount: 0.7 }}
>
<div className="label mb-3 max-md:mb-1.5">Pricing</div>
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">
Start Automation Today
</div>
</motion.div>
<motion.div
className="flex gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.7 }}
viewport={{ amount: 0.35 }}
>
{pricing.map((item, index) => (
<div
className={`relative flex flex-col flex-1 rounded-[1.25rem] overflow-hidden after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84 ${
item.title === "PRO"
? "shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-green/10 before:rounded-full before:blur-[3.375rem]"
: "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset]"
}`}
key={index}
>
{item.title === "PRO" && (
<div className="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
<video
className="w-full"
src="/videos/video-1.mp4"
autoPlay
loop
muted
playsInline
/>
</div>
)}
<div
className={`relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 ${
item.title === "PRO"
? "bg-[#175673]/20 rounded-t-[1.25rem] text-green"
: "text-white"
}`}
>
{item.title}
</div>
<div
className={`relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none ${
item.title === "PRO"
? "backdrop-blur-[2rem] shadow-2 bg-white/7"
: "backdrop-blur-[1.25rem] bg-white/1"
}`}
>
<div
className={`relative mb-8 p-5 rounded-[0.8125rem] backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none ${
item.title === "PRO"
? "bg-line"
: "bg-white/2"
}`}
>
<div className="flex items-end gap-3 mb-4">
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">
${item.price}
</div>
<div className="text-title-5">/Month</div>
</div>
<Button
className={`w-full bg-line ${
item.title !== "PRO"
? "!text-description hover:!text-white"
: ""
}`}
isPrimary={item.title === "PRO"}
isSecondary={item.title !== "PRO"}
>
{item.title === "STARTER"
? "Start with Beginner"
: item.title === "PRO"
? "Choose Pro Plan"
: "Contact for Enterprise"}
</Button>
</div>
<div className="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
{item.features.map((feature, index) => (
<div
className="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile"
key={index}
>
<div className="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg
className="size-5 fill-black"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
{feature}
</div>
))}
</div>
</div>
</div>
))}
</motion.div>
<div className="mt-13.5 text-center max-md:mt-8 max-md:text-title-3-mobile">
Free 7 Day Trial
</div>
</div>
</div>
);
export default Pricing;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

100
update_pricing_options.sql Normal file
View File

@@ -0,0 +1,100 @@
-- ============================================
-- 更新订阅套餐价格配置
-- 用途:为 subscription_plans 表添加季付、半年付价格
-- 日期2025-11-19
-- ============================================
-- 更新 Pro 专业版的 pricing_options
UPDATE subscription_plans
SET pricing_options = JSON_ARRAY(
JSON_OBJECT(
'months', 1,
'price', 299.00,
'label', '月付',
'cycle_key', 'monthly',
'discount_percent', 0
),
JSON_OBJECT(
'months', 3,
'price', 799.00,
'label', '季付',
'cycle_key', 'quarterly',
'discount_percent', 11,
'original_price', 897.00
),
JSON_OBJECT(
'months', 6,
'price', 1499.00,
'label', '半年付',
'cycle_key', 'semiannual',
'discount_percent', 16,
'original_price', 1794.00
),
JSON_OBJECT(
'months', 12,
'price', 2699.00,
'label', '年付',
'cycle_key', 'yearly',
'discount_percent', 25,
'original_price', 3588.00
)
)
WHERE name = 'pro';
-- 更新 Max 旗舰版的 pricing_options
UPDATE subscription_plans
SET pricing_options = JSON_ARRAY(
JSON_OBJECT(
'months', 1,
'price', 599.00,
'label', '月付',
'cycle_key', 'monthly',
'discount_percent', 0
),
JSON_OBJECT(
'months', 3,
'price', 1599.00,
'label', '季付',
'cycle_key', 'quarterly',
'discount_percent', 11,
'original_price', 1797.00
),
JSON_OBJECT(
'months', 6,
'price', 2999.00,
'label', '半年付',
'cycle_key', 'semiannual',
'discount_percent', 17,
'original_price', 3594.00
),
JSON_OBJECT(
'months', 12,
'price', 5399.00,
'label', '年付',
'cycle_key', 'yearly',
'discount_percent', 25,
'original_price', 7188.00
)
)
WHERE name = 'max';
-- 验证更新结果
SELECT
name AS '套餐',
display_name AS '显示名称',
pricing_options AS '价格配置'
FROM subscription_plans
WHERE name IN ('pro', 'max');
-- 完成提示
SELECT '价格配置已更新!' AS '状态';
SELECT '新价格:' AS '';
SELECT ' Pro 月付: ¥299' AS '';
SELECT ' Pro 季付: ¥799 (省11%)' AS '';
SELECT ' Pro 半年付: ¥1499 (省16%)' AS '';
SELECT ' Pro 年付: ¥2699 (省25%)' AS '';
SELECT '' AS '';
SELECT ' Max 月付: ¥599' AS '';
SELECT ' Max 季付: ¥1599 (省11%)' AS '';
SELECT ' Max 半年付: ¥2999 (省17%)' AS '';
SELECT ' Max 年付: ¥5399 (省25%)' AS '';

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