Compare commits

...

187 Commits

Author SHA1 Message Date
zdl
2bb8cb78e6 feat: 客服通知代码提交 2025-11-11 11:31:40 +08:00
zdl
8e5623d723 feat(customer-service): 集成 Bytedesk 客服系统并优化 Dify 机器人显示
## 主要变更

### 1. Dify 机器人优化
**文件**: public/index.html
-  恢复 Dify 机器人代码
-  添加显示控制逻辑:只在 /home 页面显示
-  使用 JavaScript 监听路由变化,动态控制显示/隐藏
-  保留所有样式配置

### 2. Bytedesk 客服系统集成
**文件**: src/bytedesk-integration/config/bytedesk.config.js
-  配置开发环境使用代理路径(/bytedesk-api)
-  修复 X-Frame-Options 跨域问题
-  优化 shouldShowCustomerService 逻辑:默认所有页面显示,只在 /login 隐藏
-  保留白名单模式代码作为备用方案

**文件**: src/components/GlobalComponents.js
-  集成 BytedeskWidget 组件
-  使用 shouldShowCustomerService 控制显示

### 3. 客服显示规则

**Dify 机器人**:
-  /home 页面 → 显示
-  其他页面 → 隐藏

**Bytedesk 客服**:
-  所有页面 → 显示
-  /login 页面 → 隐藏

## 已知问题

- ⚠️ Bytedesk 服务器配置 enabled: false,需要后端修改为 true
- ⚠️ 配置接口: /config/bytedesk/properties

## 测试建议

1. 访问 /home 页面,检查 Dify 机器人是否显示
2. 访问其他页面,检查 Dify 是否隐藏
3. 等待后端修改 enabled 后,测试 Bytedesk 客服功能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 19:58:36 +08:00
zdl
57b4841b4c feat: 添加客服组件 2025-11-10 19:23:25 +08:00
zdl
9e23b370fe feat: 底部UI调整 2025-11-10 14:48:28 +08:00
zdl
34bc3d1d6f feat: 调整footer间距 2025-11-10 14:48:28 +08:00
7f2a4dd36a 事件中心不提示通知修复 2025-11-10 14:20:42 +08:00
45ff13f4d0 事件中心不提示通知修复 2025-11-10 13:46:34 +08:00
a00b8bb73d 事件中心ui 2025-11-10 12:45:34 +08:00
46ba421f42 事件中心ui 2025-11-10 12:32:14 +08:00
6cd300b5ae 事件中心ui 2025-11-10 12:22:21 +08:00
617300ac8f 事件中心不提示通知修复 2025-11-10 10:47:39 +08:00
25163789ca 事件中心不提示通知修复,增加开启/关闭通知按钮。修复edge或者opera浏览器登录扫码无跳转的问题 2025-11-10 10:36:29 +08:00
fbf6813615 事件中心有引用的相关详情样式调整 2025-11-10 10:18:55 +08:00
800151771c agent功能开发增加MCP后端 2025-11-10 08:14:53 +08:00
9a723f04f1 agent功能开发增加MCP后端 2025-11-10 07:56:52 +08:00
2756e6e379 agent功能开发增加MCP后端 2025-11-08 11:32:01 +08:00
87d8b25768 agent功能开发增加MCP后端 2025-11-08 10:58:16 +08:00
6228bef5ad agent功能开发增加MCP后端 2025-11-08 10:17:48 +08:00
dff37adbbc agent功能开发增加MCP后端 2025-11-08 08:58:30 +08:00
2a228c8d6c agent功能开发增加MCP后端 2025-11-08 00:11:36 +08:00
95eb86c06a agent功能开发增加MCP后端 2025-11-07 23:51:18 +08:00
6899b9d0d2 agent功能开发增加MCP后端 2025-11-07 23:18:20 +08:00
a8edb8bde3 agent功能开发增加MCP后端 2025-11-07 23:03:22 +08:00
d8dc79d32c agent功能开发增加MCP后端 2025-11-07 22:45:46 +08:00
e29f391f10 agent功能开发增加MCP后端 2025-11-07 22:31:07 +08:00
30788648af agent功能开发增加MCP后端 2025-11-07 22:12:23 +08:00
c886d78ff6 agent功能开发增加MCP后端 2025-11-07 22:02:21 +08:00
3a058fd805 agent功能开发增加MCP后端 2025-11-07 21:46:50 +08:00
d1d8d1a25d agent功能开发增加MCP后端 2025-11-07 21:03:24 +08:00
fc5d2058c4 agent功能开发增加MCP后端 2025-11-07 20:50:16 +08:00
322b1dd845 agent功能开发增加MCP后端 2025-11-07 20:23:54 +08:00
zdl
f01eff6eb7 feat: 优化股票卡片显示
d670b0a feat: 历史股票增加相关度数据
     02c03ab feat: 修改列表默认状态
     8bdc2aa feat: 处理mock数据
2025-11-07 20:05:14 +08:00
zdl
4860cac3ca feat: 历史股票增加相关度数据 2025-11-07 20:05:14 +08:00
zdl
207701bbde feat: 修改列表默认状态 2025-11-07 20:05:14 +08:00
zdl
033f29e90c feat: 处理mock数据 2025-11-07 20:05:14 +08:00
bd9fdefdea Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event 2025-11-07 19:55:16 +08:00
4dc27a35ff agent功能开发增加MCP后端 2025-11-07 19:55:05 +08:00
zdl
0f3219143f Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event
* 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react:
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
2025-11-07 19:48:20 +08:00
zdl
00aabfacea feat: DynamicNewsDetailPanel 支持无头部模式和精简模式优化
新增功能:
- 添加 showHeader prop 控制头部显示/隐藏(默认 true)
- 无头部模式下显示 CompactMetaBar 精简信息栏(右上角浮动)
- 相关股票支持精简模式(使用 CompactStockItem + Wrap 布局)
- 添加 showModeToggle 和 simpleContent props 到相关股票模块

Bug 修复和优化:
- 修复 isStocksOpen 初始值依赖未就绪变量的问题(改为 false)
- 优化股票加载逻辑:PRO 和 MAX 会员都默认展开和自动加载
- 更新日志文案:从"PRO会员"改为"PRO/MAX会员"

导入调整:
- 添加 Wrap, WrapItem(用于精简模式布局)
- 添加 CompactMetaBar(无头部模式信息栏)
- 添加 CompactStockItem(精简模式股票卡片)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:48:08 +08:00
zdl
7b49062986 docs: 更新 Community 文档
- 补充精简/详细模式切换功能文档
- 添加无头部模式(showHeader)使用说明
- 更新 CollapsibleSection 和 DynamicNewsDetailPanel 的 API 参考
- 添加相关组件的使用示例

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:47:14 +08:00
zdl
52c3e25218 feat: HistoricalEvents UI 布局优化
- 从网格布局(SimpleGrid 3列)改为单列纵向布局(VStack)
- 卡片样式优化:添加顶部渐变条装饰(蓝-紫-粉渐变)
- 卡片内部从垂直布局改为横向布局(HStack)
- 优化间距和边距,提升视觉层次感
- 调整卡片padding和borderRadius

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:46:56 +08:00
zdl
4979293320 feat: RelatedConceptsSection 支持受控模式和优化
- 新增 isOpen, onToggle props 支持外部控制展开状态(受控模式)
- 添加 hasNoConcepts 判断,优化空数据处理逻辑
- 改进精简模式和详细模式的空状态显示
- 增强点击处理逻辑,支持受控/非受控两种模式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:46:29 +08:00
463ca7cf60 agent功能开发增加MCP后端 2025-11-07 19:35:37 +08:00
zdl
b30cbd6c62 RelatedStocksSection 重构为纯详细模式组件 2025-11-07 19:32:36 +08:00
zdl
11789b5ec7 Commit 2: CollapsibleSection 支持精简/详细双模式 2025-11-07 19:32:10 +08:00
zdl
63fb8a3aa8 feat: 功能: │ │
│ │ - 新增 showModeToggle, currentMode, onModeToggle 等 props                                                                                      │ │
│ │ - 支持显示模式切换按钮("精简模式" / "查看详情")                                                                                              │ │
│ │ - 根据模式动态显示按钮文案和图标
2025-11-07 19:31:42 +08:00
7366769083 agent功能开发增加MCP后端 2025-11-07 19:30:51 +08:00
zdl
2da71a3c03 feat: 相关股票添加合规 2025-11-07 19:29:19 +08:00
a46247f81b agent功能开发增加MCP后端 2025-11-07 19:27:01 +08:00
zdl
44b8c64907 feat(community): 列表模式事件卡片高度自适应 2025-11-07 19:25:10 +08:00
315d606945 agent功能开发增加MCP后端 2025-11-07 19:11:58 +08:00
zdl
5ceffc53d6 feat: 事件中心详情面板Ui调整 2025-11-07 18:39:49 +08:00
446d8f0870 agent功能开发增加MCP后端 2025-11-07 18:15:41 +08:00
e7ba8c4c2d agent功能开发增加MCP后端 2025-11-07 18:11:29 +08:00
a1c76a257c agent功能开发增加MCP后端 2025-11-07 17:42:06 +08:00
zdl
3574f5391f feat: 动画调整 2025-11-07 15:17:57 +08:00
zdl
fef9087c47 feat: 调整事件详情滑动不触发外部页面滑动 2025-11-07 15:11:18 +08:00
zdl
b0b42e9d3d feat: 添加post postHog加上 2025-11-07 15:10:27 +08:00
zdl
09f15d2e03 feat: 添加本地通知测试 2025-11-07 15:09:07 +08:00
zdl
a6718e1be5 pref: 删除无效代码 2025-11-07 15:08:46 +08:00
zdl
e93e307ad8 feat: 添加权限通知文档 2025-11-07 15:08:29 +08:00
zdl
16d60ef773 feat: 更新md文档 2025-11-07 15:07:38 +08:00
zdl
4d389bcc10 feat: 配置调整; 2025-11-07 14:48:27 +08:00
zdl
c10af30ad4 feat: 删除不需要的组件 2025-11-07 14:31:50 +08:00
zdl
3c060b7aa5 feat: 事件详情添加浏览量点击机制 2025-11-07 14:16:11 +08:00
zdl
72e9456aba feat: Community 页面有了自己独立的技术文档 2025-11-07 14:01:24 +08:00
zdl
0e82c96c5a feat: CLAUDE.md **🌐 语言偏好** 2025-11-07 14:00:57 +08:00
zdl
9c93843f75 feat: 删除无用代码 2025-11-07 13:19:51 +08:00
zdl
184c26d323 feat: 添加通知组件调试信息 2025-11-07 12:34:05 +08:00
zdl
e80227840a feat: 补充md文档 2025-11-07 12:19:41 +08:00
zdl
e4490b54e0 feat: CLAUDE.md 文档已经完全中文化 2025-11-07 12:19:41 +08:00
83cd875690 事件中心UI优化 2025-11-07 11:20:45 +08:00
25d3bf4d95 事件中心UI优化 2025-11-07 11:08:06 +08:00
7adb4ea8af Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event 2025-11-07 10:56:21 +08:00
3eff0554f9 事件中心UI优化 2025-11-07 10:56:08 +08:00
zdl
0e015901ea feat: 删除不需要的组件 2025-11-07 10:35:20 +08:00
2a122b0013 事件中心UI优化 2025-11-07 10:31:42 +08:00
663d73609a 事件中心UI优化 2025-11-07 10:16:21 +08:00
389a45fc0a 事件中心UI优化 2025-11-07 09:57:49 +08:00
67c7fa49e8 事件中心UI优化 2025-11-07 09:45:42 +08:00
a3810499cc 优惠码Bug修复 2025-11-07 08:13:12 +08:00
83c6abdfba 优惠码Bug修复 2025-11-07 07:53:07 +08:00
dcc88251df 优惠码Bug修复 2025-11-07 07:35:13 +08:00
zdl
6271736969 fix: 修复重置按钮不生效问题
问题描述:
- 用户选择所有筛选条件后,点击"重置"按钮无反应
- 筛选条件未被清空,事件列表未重新加载

根本原因:
- 当筛选条件从"有值"重置为"空值"或从"空值"重置为"空值"时
- 如果 filters 对象的字段值没有实质变化
- DynamicNewsCard 的 useEffect 依赖项检测不到变化,不会触发重新加载

解决方案:
1. UnifiedSearchBox.handleReset() 添加 _forceRefresh 时间戳标志
   - 每次重置都生成唯一的 Date.now() 时间戳
   - 确保 filters 对象每次重置都不同

2. DynamicNewsCard 筛选 useEffect 依赖数组添加 filters._forceRefresh
   - 监听强制刷新标志的变化
   - 即使其他筛选条件未变,也能触发重新加载

3. 增强调试日志
   - 添加完整的重置流程日志输出
   - 便于排查后续问题

修改文件:
- src/views/Community/components/UnifiedSearchBox.js (Line 505-536)
- src/views/Community/components/DynamicNewsCard.js (Line 264)

测试场景:
 选择所有筛选条件后点击重置 - 清空并重新加载
 未选择筛选条件时点击重置 - 强制刷新第1页
 重置后 Redux 缓存被清空 (clearCache: true)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 18:00:53 +08:00
zdl
319a78d34c fix: 修复分页、筛选和模式切换相关问题
主要修复:
1. 修复模式切换时 per_page 参数错误
   - 在 useEffect 内直接根据 mode 计算 per_page
   - 避免使用可能过时的 pageSize prop

2. 修复 DISPLAY_MODES 未定义错误
   - 在 DynamicNewsCard.js 中导入 DISPLAY_MODES 常量

3. 添加空状态显示
   - VerticalModeLayout 添加无数据时的友好提示
   - 显示图标和提示文字,引导用户调整筛选条件

4. 修复无限请求循环问题
   - 移除模式切换 useEffect 中的 filters 依赖
   - 避免筛选和模式切换 useEffect 互相触发

5. 修复筛选参数传递问题
   - usePagination 使用 useRef 存储最新 filters
   - 避免 useCallback 闭包捕获旧值
   - 修复时间筛选参数丢失问题

6. 修复分页竞态条件
   - 允许用户在加载时切换到不同页面
   - 只阻止相同页面的重复请求

涉及文件:
- src/views/Community/components/DynamicNewsCard.js
- src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
- src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
- src/views/Community/hooks/useEventFilters.js
- src/store/slices/communityDataSlice.js
- src/views/Community/components/UnifiedSearchBox.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 17:39:03 +08:00
zdl
8799964961 refactor: 恢复 TradingTimeFilter 到原版本
- 移除 timeRange prop 及其同步逻辑
- 恢复原有的 value 同步逻辑
- 简化组件接口

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 15:33:54 +08:00
zdl
42808501b0 refactor: 删除 FilterModal 筛选弹窗组件
- 移除 FilterModal.js 文件
- 简化组件结构,筛选功能保留在 CardHeader 的 UnifiedSearchBox 中

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 15:33:36 +08:00
zdl
291362b88d feat: VerticalModeLayout 详情/列表模式自动切换
- 点击事件自动切换到详情模式
- 切换到列表模式时重置详情面板(通过 key 强制重新渲染)
- 添加独立滚动容器,支持左右两侧独立滚动
- 优化布局高度控制,使用 h="100%" 撑满父容器

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 14:13:06 +08:00
zdl
f5328ec3a1 fix: 修复 EventScrollList 和 EventDetailScrollPanel 布局问题
- EventScrollList.js: 添加 h="100%" 和 data-scroll-container 属性,支持独立滚动
- EventDetailScrollPanel.js: 移除 maxHeight 限制,允许详情面板撑满容器高度
- 修复布局显示问题,优化滚动体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 14:12:05 +08:00
zdl
52cf950b21 feat: 创建 FilterModal 筛选弹窗组件
- 新增 FilterModal.js 组件,用于在固定模式下显示筛选弹窗
- 复用 UnifiedSearchBox 组件实现筛选功能
- 支持 mode 和 pageSize 参数传递
- 添加 scrollBehavior="outside" 避免下拉菜单被遮挡

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 14:09:59 +08:00
zdl
f9b580c871 feat: bundle升级 2025-11-06 13:30:28 +08:00
zdl
8b25d5d91c feat: 时间筛选重置调整 2025-11-06 12:41:32 +08:00
zdl
c6b3b56cb8 feat: 搜索框布局调整 2025-11-06 12:40:58 +08:00
zdl
42f1b2f24e feat: 热门关键词展示一行,且不展示股票数量 2025-11-06 12:37:37 +08:00
zdl
935c933cb8 feat: 搜索框接入交易时间段筛选能力 2025-11-06 11:48:31 +08:00
zdl
f4b58b42cc feat: 添加交易时间段筛选组件 2025-11-06 11:46:31 +08:00
zdl
5ff8db8899 pref: UI优化 2025-11-06 11:35:10 +08:00
zdl
116594d9b1 pref: 去掉无用代码 2025-11-06 10:26:43 +08:00
zdl
ca5adb3ad2 feat: 从替换式渲染 → 蒙层式渲染
之前的问题:

  - Loading 时替换整个列表组件
  - 组件频繁挂载/卸载,性能差
  - 切换模式时界面跳动明显

  现在的方案:

  -  列表组件始终渲染(避免频繁挂载卸载)
  -  Loading 通过蒙层叠加显示
  -  旧数据保持可见直到新数据加载完成
  -  更平滑的视觉过渡
2025-11-06 10:17:10 +08:00
zdl
8eaaef1666 Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event
* 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react:
  加入优惠码机制,预置3个优惠码
2025-11-06 01:40:28 +08:00
zdl
ebb737427f fix: 优化模式切换体验和渲染逻辑
## 问题修复
1. 模式切换时不再闪现"暂无事件数据"
2. 模式切换按钮始终可见,不会因加载状态而隐藏

## 技术改进
- 将控制栏(模式切换+分页)提取到 EventScrollList 外层
- 使用 mode(立即同步)而非 currentMode(延迟一帧)检查缓存
- 优化渲染顺序:loading → 数据 → 空状态,避免闪烁

## 文件修改
- DynamicNewsCard.js: 添加控制栏导入,优化渲染逻辑
- EventScrollList.js: 移除重复的控制栏代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 01:40:24 +08:00
zdl
31e5a4ee48 feat: 添加 RTK Query 集成用于事件数据获取(实验性)... 2025-11-06 01:25:44 +08:00
zdl
273ff5f72d feat: 相关概念添加 PRO 权限控制... 2025-11-06 01:20:33 +08:00
zdl
a5e001d975 refactor: 优化分页存储架构和缓存逻辑... 2025-11-06 01:20:07 +08:00
zdl
c5d6247f49 fix: 修复 MSW 接口和调试代码清理... 2025-11-06 01:17:06 +08:00
zdl
ad933e9fb2 feat: UI调整 2025-11-05 23:54:43 +08:00
zdl
adf6fc7780 feat:平铺模式 | 隐藏顶部分页控制器" 2025-11-05 22:34:07 +08:00
zdl
6930878ff6 refactor: 删除未使用的 lastUpdated 和 cachedCount 状态
- 删除 initialState 中的 lastUpdated 和 cachedCount
  - 删除所有 reducer 中相关的设置代码
  - 更新 selectors 使用 .length 替代 cachedCount
  - 删除 shouldRefresh 工具函数

  简化理由:
  - lastUpdated 未被使用
  - cachedCount 可以通过 events.length 直接获取
2025-11-05 22:33:25 +08:00
zdl
ed24a14fbf feat: 事件详情权限加上权限校验 2025-11-05 21:31:02 +08:00
zdl
25a6ff164b feat: 翻页bugfix 2025-11-05 19:28:17 +08:00
zdl
612b58c983 feat: feat: 优化事件卡片 UI 和交互体验
修复 useColorModeValue 调用位置(提升到顶层)
优化分页和滚动逻辑
动态 indicatorSize 支持(detail/list 模式)
2025-11-05 19:15:36 +08:00
zdl
27b68e928e feat: bugfix 2025-11-05 19:06:18 +08:00
zdl
e6ffb0dc74 Redux 相关修改 2025-11-05 19:01:56 +08:00
zdl
2355004dfb fix: refactor: 简化 Redux 数据管理逻辑并修复 bug
修复 clearCache/clearSpecificCache 引用不存在的 state.dynamicNews bug
简化数据插入逻辑,移除复杂的 Append/Replace/Jump 模式(虚拟滚动接管)
添加空数据边界处理和 toast 提示
添加 mode 参数支持(vertical/four-row)
修复默认值解构避免 undefined 错误
修复 Redux slice 未使用参数的 TS 警告 仅 preloadData 和 toggleEventFollow.rejected 的参数修改 将未使用的 state 参数改为 _state 前缀,消除 TS6133 警告
2025-11-05 19:00:53 +08:00
zdl
c5dcb4897d fix: 修复 CollapsibleHeader Box 组件导入缺失 2025-11-05 18:58:19 +08:00
zdl
dc0c8e2c60 feat: UI调整 2025-11-05 18:04:49 +08:00
zdl
2e89469d05 feat: 调整纵向列表UI 2025-11-05 17:50:33 +08:00
zdl
e617eddd46 feat: 添加mock数据 2025-11-05 17:49:15 +08:00
zdl
22186eb54a feat: 添加mock数据 2025-11-05 17:43:17 +08:00
zdl
c3ef837221 feat: 纵向详情列表高度控制 2025-11-05 17:33:28 +08:00
zdl
870b1f5996 feat: 多列布局ui调整 2025-11-05 17:30:21 +08:00
zdl
bc2a3b71c0 pref: 代码优化 2025-11-05 17:08:01 +08:00
zdl
ff7b8abe9d feat: 去除不相关逻辑 2025-11-05 17:02:49 +08:00
zdl
cb44c18e57 feat: 热门事件点击打开弹窗 2025-11-05 17:01:19 +08:00
zdl
623ec73c62 feat: 添加mock数据 2025-11-05 16:49:13 +08:00
zdl
4c08ef57ff feat: 股票涨跌幅指标组件 2025-11-05 16:49:04 +08:00
zdl
ca52d3bd87 feat: 纵向列表(HorizontalDynamicNewsEventCard.js:105-133) - 添加 Tooltip 提示
平铺列表(DynamicNewsEventCard.js:232) - 修改行数限制
2025-11-05 16:40:35 +08:00
zdl
62ae2e0803 feat: 提取 ImportanceBadge 组件 2025-11-05 16:15:18 +08:00
zdl
7e781731c4 feat: mock数据添加 2025-11-05 15:20:59 +08:00
zdl
0765f8a800 feat: 纵向布局分页模式优化 2025-11-05 15:20:43 +08:00
zdl
70dbf3b492 feat: StockChangeIndicators 组件优化 2025-11-05 15:19:48 +08:00
zdl
aa1a93c65b feat: 重要性徽章样式优化(圆形设计) 2025-11-05 15:19:02 +08:00
zdl
f9e4265dd6 feat: 配置完全mock环境 2025-11-05 15:00:11 +08:00
1361a2b5b2 加入优惠码机制,预置3个优惠码 2025-11-05 14:39:20 +08:00
zdl
263ecd77b3 feat: 添加详情面板和事件详情切换按钮 2025-11-05 14:08:03 +08:00
zdl
b6862aff4f feat: 提取 EventDetailScrollPanel 2025-11-05 14:00:22 +08:00
zdl
327cfc09e2 feat: 提取VerticalModeLayout - 提升可读性,但耦合度中等 2025-11-05 13:57:05 +08:00
zdl
f5d340aa05 feat: 提取VerticalModeLayout - 提升可读性,但耦合度中等 2025-11-05 13:56:52 +08:00
zdl
0da18e868a refactor: 提取 ModeToggleButtons 为独立子组件
问题:
- EventScrollList 组件中模式切换按钮代码内联(17行)
- 降低代码可读性和可维护性
- 按钮组无法在其他地方复用

修改:
1. 新建 ModeToggleButtons.js 独立组件
   - 接收 mode 和 onModeChange 两个 props
   - 包含完整的 JSDoc 注释
   - 支持 vertical(纵向)和 four-row(平铺)两种模式

2. 重构 EventScrollList.js
   - 删除未使用的 import(Button, ButtonGroup)
   - 导入 ModeToggleButtons 组件
   - 替换 17 行内联代码为 1 行组件调用
   - 代码净减少 14 行

效果:
-  职责分离:模式切换逻辑独立封装
-  可复用性:其他页面可直接导入使用
-  易维护性:修改按钮样式只需改一个文件
-  易测试性:可单独编写单元测试
-  代码简洁:EventScrollList 更简洁易读

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:44:34 +08:00
zdl
0f7693939a refactor: 删除单排和双排模式,简化事件列表展示
问题:
- 事件列表组件包含4种模式(单排/双排/纵向/平铺)
- 单排(carousel)和双排(grid)模式代码已被注释,未实际使用
- 保留未使用代码增加维护成本和代码复杂度

修改:
1. 删除未使用的 import(DynamicNewsEventCard, CompactEventCard, Spinner, HStack)
2. 删除加载遮罩相关代码(仅单排/双排模式使用)
3. 删除已注释的单排/双排切换按钮代码
4. 删除单排轮播模式完整实现(~32行)
5. 删除双排网格模式完整实现(~33行)
6. 更新组件注释:明确只支持纵向和平铺两种模式
7. 更新默认模式:carousel → vertical
8. 简化条件判断(overflowX/overflowY/maxH)

效果:
- 代码从 361 行缩减到 254 行(删除 ~107 行)
- 只保留两种实际使用的模式:纵向(vertical)和平铺(four-row)
- 降低代码复杂度,提升可维护性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:37:18 +08:00
zdl
becd0268a6 refactor: 调整事件详情面板中相关股票和相关概念的显示顺序
修改内容:
- 将"相关股票"移到"相关概念"之前显示
- 优化用户体验,优先展示用户更关心的股票信息

新的显示顺序:
1. 事件描述
2. 相关股票 ← 现在排在前面
3. 相关概念 ← 现在排在后面
4. 历史事件对比
5. 传导链分析

修改文件:
- src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:24:01 +08:00
zdl
8bd7801753 feat: 平铺模式隐藏分页控制,改用无限滚动
问题:
- 平铺模式使用虚拟滚动+无限滚动加载数据
- 但仍显示传统分页控制器和翻页按钮
- 分页控制与无限滚动机制冲突,用户体验不一致

修复:
- 平铺模式下隐藏 PaginationControl(分页控制器)
- 平铺模式下隐藏 PageNavigationButton(左右翻页按钮)
- 添加注释说明:平铺模式使用无限滚动

效果:
- 平铺模式: 仅显示模式切换按钮,使用无限滚动
- 其他模式(纵向/单排/双排): 保持分页控制器和翻页按钮

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:22:10 +08:00
zdl
d4c731730f fix: 修复 Mock 模式下 webpack proxy 配置和优化构建配置
问题:
1. Mock 模式下 webpack devServer proxy 在服务器层转发请求,导致 MSW 无法拦截
2. Chakra UI 和 Ant Design 的 cacheGroups priority 相同(22),可能导致分割冲突
3. maxSize 限制 244KB 过小,导致中型库过度分割
4. 缺少 Mock 模式调试日志

修复:
1. 添加 isMockMode() 工具函数(与 apiConfig.js 保持一致)
2. Mock 模式下禁用 proxy,让 MSW 在浏览器层拦截请求
3. 添加 onListening 钩子打印 Mock 模式和 Proxy 状态
4. 修复 Chakra UI priority: 22 → 23(避免与 Ant Design 冲突)
5. 优化 maxSize: 244KB → 512KB(与 performance.maxAssetSize 一致)

效果:
- Mock 模式:proxy 禁用 → MSW 拦截 → 返回 mock 数据 
- 真实后端:proxy 启用 → 转发到后端服务器 
- 减少过度分割,降低 HTTP 请求数,提升加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:12:51 +08:00
zdl
fe9b3034a1 feat: 优化股票卡片布局和弹窗性能
布局优化:
- 将分时图和K线图移至第二行公司名称后面
- 第二行布局:公司名称(左)+ 分时图 + K线图(右)
- 删除图表标题文字,使布局更紧凑
- 移除未使用的 SimpleGrid 导入

性能优化:
- 股票详情弹窗改为条件渲染
- 弹窗关闭时完全从 DOM 移除
- 减少不必要的组件渲染和内存占用
- 与四排模式弹窗保持一致的实现方式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 11:09:53 +08:00
zdl
ea0428321b fix: 修复纵向模式右侧详情面板滚动问题
问题描述:
- 纵向模式下,展开"相关股票"等内容后,整个页面滚动而不是右侧详情面板内部滚动
- 右侧详情面板没有独立的滚动条

根本原因:
- 外层容器没有高度限制,随内容无限增长
- Grid 使用 minH/maxH 而非固定高度
- 内层 Box 的 overflow 样式被 Chakra UI 默认样式覆盖

解决方案:
1. 外层容器(line 160):添加纵向模式的最大高度限制 820px
2. Grid(line 293):使用固定高度 h="800px" 替代 minH/maxH
3. 右侧 Box(line 315-337):
   - 使用 sx prop + !important 强制应用 overflow 和 height 样式
   - 滚动条宽度优化为 3px(原 1px 太细,临时 8px 太粗)
   - 使用动态颜色变量保持主题一致性

修改文件:
- src/views/Community/components/DynamicNewsCard/EventScrollList.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 11:07:41 +08:00
zdl
d95bd51206 feat: 导航栏关注事件弹窗增强显示信息
- 添加热度显示(🔥 图标 + 分数)
  - ≥80 红色,≥60 橙色,<60 灰色
- 添加关注数显示(👥 图标 + 人数)
- 保留原有涨跌幅显示(日均、周涨)
- mock 数据补充涨跌幅字段(related_avg_chg, related_max_chg, related_week_chg)
- 智能显示:字段存在时才显示对应 Badge
- 优化 Badge 间距和布局

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 11:05:32 +08:00
zdl
69d4b8bae0 feat: k线图自适应 2025-11-05 10:10:22 +08:00
zdl
bf89c0e13e fix: 修复纵向模式右侧详情折叠展开后无法滑动的问题
问题描述:
- 纵向模式下,右侧详情面板中的折叠区块(相关股票、历史事件等)展开后
- 右侧面板无法滚动,用户无法查看完整内容

根本原因:
- Chakra UI Collapse 组件在动画过程中设置 overflow: hidden
- 动画结束后可能没有正确恢复,影响父容器的滚动功能
- 嵌套滚动容器之间存在冲突

解决方案:

1. CollapsibleSection.js
   - 为 Collapse 组件添加 unmountOnExit={false}
   - 添加 startingHeight={0} 确保动画从 0 开始
   - 防止 Collapse 动画干扰父容器的 overflow 属性

2. EventScrollList.js
   - 为右侧详情 Box 添加 position="relative"
   - 使用 overflow: auto !important 强制保持滚动功能
   - 确保即使子元素有 overflow 设置也不受影响

技术细节:
- unmountOnExit={false} 保持 DOM 节点存在,避免频繁挂载/卸载
- startingHeight={0} 确保折叠动画的起始高度一致
- !important 提高 CSS 优先级,覆盖子元素的 overflow 设置
- position: relative 创建新的层叠上下文,隔离滚动行为

影响范围:
- 纵向模式右侧详情面板
- 所有使用 CollapsibleSection 的区块

测试建议:
1. 切换到纵向模式
2. 展开"相关股票"或其他折叠区块
3. 尝试滚动右侧详情面板
4. 确认可以正常查看所有内容

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:58:52 +08:00
zdl
4e7fcaad5c fix: 修复详情面板"相关股票"重复标题的问题
问题描述:
- 详情面板中出现两个"相关股票"标题
- 用户反馈截图显示标题重复渲染

根本原因:
- DynamicNewsDetailPanel 使用 CollapsibleSection 包裹 RelatedStocksSection
- RelatedStocksSection 内部又渲染了 CollapsibleHeader
- 导致双重标题渲染

解决方案:
1. RelatedStocksSection.js
   - 移除内部的 CollapsibleHeader 和 Collapse 组件
   - 只保留纯内容部分(股票网格)
   - 简化组件职责:仅负责渲染股票列表

2. DynamicNewsDetailPanel.js
   - 移除传递给 RelatedStocksSection 的 isOpen 和 onToggle props
   - 折叠逻辑由外层的 CollapsibleSection 统一管理

修改文件:
- RelatedStocksSection.js - 移除重复的标题和折叠逻辑
- DynamicNewsDetailPanel.js - 清理无用的 props 传递

影响范围:
- 事件详情面板的"相关股票"区块

测试建议:
1. 打开事件详情面板
2. 展开"相关股票"区块
3. 确认只有一个标题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:54:50 +08:00
zdl
41baf16d45 fix: 纵向模式右侧详情滚动条改为常显
- 从 hover 显示改为始终显示 (1px)
- 用户反馈更倾向于始终可见的滚动条
- 提供持续的滚动位置反馈

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:51:13 +08:00
zdl
c5b8fe91c3 feat: 实现纵向模式和平铺模式的双向无限滚动
问题描述:
- 纵向模式下,用户向上滑动触发懒加载后,向下滑动无法回到之前的内容
- 原因:纵向模式未启用累积模式,且缺少向上滚动加载上一页的功能

解决方案:
实现类似社交媒体的双向无限滚动机制:
- 向下滚动到 60% 时自动加载下一页(新内容)
- 向上滚动到顶部 10% 时自动加载上一页(旧内容)
- 加载上一页后自动调整滚动位置,保持用户视图不跳动

技术实现:

1. usePagination.js
   - 将 VERTICAL 模式加入累积模式判断 (line 57)
   - 实现 loadPrevPage 方法,支持加载上一页 (lines 285-306)
   - 导出 loadPrevPage 供组件使用 (line 364)

2. VirtualizedFourRowGrid.js
   - 添加 loadPrevPage prop 和 previousScrollHeight ref
   - 合并双向滚动检测逻辑 (lines 67-102):
     * 向下滚动: scrollPercentage > 0.6 触发 loadNextPage
     * 向上滚动: scrollTop < clientHeight * 0.1 触发 loadPrevPage
   - 实现滚动位置保持机制 (lines 133-161):
     * 记录加载前的 scrollHeight
     * 加载完成后计算高度差
     * 调整 scrollTop += heightDifference 保持视图位置

3. DynamicNewsCard.js
   - 从 usePagination 获取 loadPrevPage
   - 传递给 EventScrollList 组件

4. EventScrollList.js
   - 接收并传递 loadPrevPage 到 VirtualizedFourRowGrid
   - 四排模式和纵向模式均支持双向滚动

影响范围:
- 纵向模式 (vertical mode)
- 平铺模式 (four-row mode)

测试建议:
1. 切换到纵向模式
2. 向下滚动观察是否自动加载下一页
3. 向上滚动到顶部观察是否:
   - 自动加载上一页
   - 滚动位置保持不变,内容不跳动
4. 切换到平铺模式验证双向滚动同样生效

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:48:01 +08:00
zdl
f919ce255a feat: 优化平铺模式的无限滚动触发机制
问题描述:
- 平铺模式下,当容器高度为 800px 但首页内容不足 800px 时
- 无法生成滚动条,导致无限滚动条件永远无法触发
- 用户需要手动翻页才能看到第二页内容

优化方案:
1. 降低滚动触发阈值
   - 从 80% 降低到 60%,更早触发下一页加载
   - 提升用户滚动体验,减少等待时间

2. 新增主动内容检测机制
   - 延迟 500ms 检测虚拟滚动渲染完成后的实际内容高度
   - 如果内容高度 ≤ 容器高度(无滚动条),自动加载下一页
   - 使用 isLoadingMore ref 防止重复触发

技术实现:
- VirtualizedFourRowGrid.js
  - 修改滚动阈值: scrollPercentage > 0.6 (line 78)
  - 新增 useEffect 监听 events.length 变化 (lines 90-117)
  - 条件判断: scrollHeight <= clientHeight 时主动加载

影响范围:
- 平铺模式 (four-row mode)

测试建议:
1. 切换到平铺模式
2. 观察首页数据少于 6 条时,是否自动加载第二页
3. 验证有足够数据时,滚动到 60% 是否正常触发加载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:36:21 +08:00
zdl
64de7d055b fix: 修复模式切换时丢失筛选条件的问题
问题描述:
- 用户在单排/双排/纵向模式下应用筛选条件后,切换到平铺模式时筛选条件丢失
- usePagination hook 在模式切换时重新请求数据,但未传递筛选参数

修复内容:
1. usePagination.js
   - 新增 filters 参数接收筛选条件
   - handleModeToggle 函数在发起请求时应用 ...filters
   - 将 filters 添加到依赖数组,确保筛选条件变化时重新执行

2. DynamicNewsCard.js
   - 将 filters 传递给 usePagination hook
   - 确保筛选条件在模式切换时保持一致

影响范围:
- 所有展示模式切换(单排、双排、纵向、平铺)

测试建议:
1. 应用任意筛选条件(如排序、重要性、关键词)
2. 切换到平铺模式
3. 验证筛选条件是否保持生效

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:35:35 +08:00
zdl
b223be2f01 refactor: 提取翻页按钮为可复用组件
- 创建 PageNavigationButton 组件
  · 通过 direction 参数区分上一页/下一页
  · 内置主题适配和模式判断逻辑
  · 自动处理显示条件(只在单排/双排模式显示)

- 重构 EventScrollList
  · 删除重复的翻页按钮代码(减少 66 行)
  · 使用 PageNavigationButton 组件替换原有按钮
  · 移除未使用的导入(IconButton, ChevronLeftIcon, ChevronRightIcon)
  · 移除翻页按钮主题色定义(已移至子组件)

优点:
- 消除重复代码,提升可维护性
- 职责分离,逻辑更清晰
- 易于扩展(可添加首页/末页按钮)
2025-11-05 09:15:33 +08:00
zdl
188783a8d2 feat: 实现动态新闻筛选功能并优化虚拟滚动
## 主要改进

### 1. 修复筛选功能
- **问题**: 筛选触发了 API 请求但列表未更新
- **根因**: fetchDynamicNews 硬编码 sort: 'new',未支持筛选参数
- **解决**:
  - Redux action 添加筛选参数支持 (sort, importance, q, date_range, industry_code)
  - DynamicNewsCard 监听 filters 变化并重新请求数据
  - 筛选时清空缓存并从第1页开始加载

### 2. 虚拟滚动优化
- 改造 VirtualizedFourRowGrid 支持多列布局
  - 添加 columnsPerRow prop (默认4列,传1实现单列)
  - 添加 CardComponent prop (支持不同卡片组件)
  - 单列模式使用更小的 gap 间距
- 纵向模式使用虚拟滚动 + 无限滚动
  - 左侧事件列表使用 VirtualizedFourRowGrid (columnsPerRow=1)
  - 使用 HorizontalDynamicNewsEventCard 横向卡片
  - 支持滚动到底部自动加载

### 3. UI 交互优化
- 默认模式改为纵向模式 (左侧列表 + 右侧详情)
- 四排/纵向模式不显示全局 loading 遮罩
- 四排模式弹窗在关闭时不渲染 (性能优化)
- 注释掉单排/双排按钮,只保留纵向和平铺模式

## 技术细节

**数据流**:
```
用户筛选 → updateFilters → filters state 更新
→ DynamicNewsCard useEffect 监听
→ dispatch(fetchDynamicNews({ ...filters, clearCache: true }))
→ API 请求(带筛选参数)
→ Redux state 更新 → 列表重新渲染
```

**虚拟滚动**:
- @tanstack/react-virtual 动态高度测量
- 80% 滚动深度触发无限加载
- 底部 loading 指示器(绝对定位)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 09:09:03 +08:00
zdl
d7f27e428b feat: 去掉市场复盘相关代码 2025-11-05 09:06:02 +08:00
zdl
f9387ffbd9 feat: 集成四排/纵向模式UI和优化交互逻辑
VirtualizedFourRowGrid:
- 组件通用化,支持多列布局(通过 columnsPerRow 参数配置)
- 支持自定义卡片组件(通过 CardComponent 参数传入)
- 根据列数动态调整间距

EventScrollList:
- 添加四排和纵向模式切换按钮
- 集成 VirtualizedFourRowGrid 组件(四排模式使用4列,纵向模式使用1列)
- 添加纵向分栏布局(1:2 比例,左侧列表+右侧详情)
- 启用纵向滚动和统一滚动条样式
- 接收新 props: displayEvents, isAccumulateMode, loadNextPage, onFourRowEventClick

DynamicNewsCard:
- 添加 Modal 弹窗显示四排模式详情
- 优化自动选中逻辑:
  · 首次加载时自动选中第一个事件
  · 翻页时,单排/双排/纵向模式自动选中当前页第一个事件(保持详情显示)
  · 翻页时,四排模式清空选中状态(通过弹窗显示详情)
- 传递新 props 到 EventScrollList
- 添加调试日志
2025-11-05 08:56:44 +08:00
zdl
be0c53b588 feat: 虚拟化网格组件通用化 │ │
│ │                                                                                                                                                   │ │
│ │ - 支持多列布局(columnsPerRow 参数,默认4列)                                                                                                     │ │
│ │ - 支持自定义卡片组件(CardComponent 参数)                                                                                                        │ │
│ │ - 根据列数动态调整间距(单列 gap=3,多列 gap=4)                                                                                                  │ │
│ │ - 更新注释和文档
2025-11-05 08:53:07 +08:00
zdl
de1b31c70e feat: git commit -m "feat: 简化分页逻辑并添加累积模式支持 │ │
│ │                                                                                                                                                   │ │
│ │ - 移除复杂的预加载逻辑(calculatePreloadRange、findMissingPages)                                                                                 │ │
│ │ - 添加累积显示模式(accumulatedEvents、isAccumulateMode)                                                                                         │ │
│ │ - 添加 displayEvents(累积或分页二选一)                                                                                                          │ │
│ │ - 添加 loadNextPage 方法用于无限滚动                                                                                                              │ │
│ │ - 支持4种显示模式的pageSize计算                                                                                                                   │ │
│ │ - 简化 handlePageChange 逻辑"
2025-11-05 08:42:10 +08:00
zdl
d96ebd6b8c feat: 创建无限滚动Hook │ │
│ │                                                                                                                                                   │ │
│ │ - 监听容器滚动事件                                                                                                                                │ │
│ │ - 距离底部阈值可配置(默认200px)                                                                                                                 │ │
│ │ - 自动触发onLoadMore回调                                                                                                                          │ │
│ │ - 支持加载状态管理
2025-11-05 08:39:28 +08:00
zdl
67127aa615 feat: 创建虚拟化四排网格组件 2025-11-05 08:32:54 +08:00
zdl
e7c495a8b1 feat: feat: 实现事件详情子模块懒加载useEventStocks添加 autoLoad 参数和分离加载函数 │ │
│ │   - DynamicNewsDetailPanel实现子模块折叠和懒加载
2025-11-05 08:29:44 +08:00
zdl
e0cfa6fab2 feat: 创建纵向模式的横向卡片组件 2025-11-05 08:26:05 +08:00
zdl
c51d3811e5 feat: 添加 @tanstack/react-virtual 依赖 2025-11-05 08:24:28 +08:00
zdl
8fe13c9fa4 feat: 概念股票列表支持滚动查看全部数据 2025-11-05 08:12:03 +08:00
zdl
e6c422887c feat:使用 ref 避免 filters 依赖导致回调重新创建 2025-11-05 08:11:30 +08:00
zdl
7e110111c4 feat: 添加 FOUR_ROW 和 VERTICAL 模式常量及页面大小配置 2025-11-05 08:09:44 +08:00
zdl
38d1b51af3 feat: 修改更新依赖 2025-11-04 20:19:01 +08:00
zdl
c7334191e5 feat: 调整mock数据 2025-11-04 20:17:56 +08:00
zdl
7fdc9e26af feat: 历史事件对比没数据数量展示0 2025-11-04 20:07:21 +08:00
zdl
7f01a391e0 feat: 关闭posthog日志 2025-11-04 19:51:41 +08:00
zdl
58db08ca22 feat: 历史事件添加涨幅字段 2025-11-04 19:50:32 +08:00
zdl
bf75f9b387 feat: 添加超预期的分提示 2025-11-04 19:39:46 +08:00
zdl
2a59e9edb2 feat: 添加合规提示 2025-11-04 19:26:18 +08:00
zdl
87476226c3 feat: 行业标签展示文字 2025-11-04 19:17:39 +08:00
zdl
76360102bb feat: 相关概念UI调整 2025-11-04 18:22:26 +08:00
zdl
1a3987afe0 feature: 重要性支持多选 2025-11-04 17:53:42 +08:00
zdl
a512f3bd7e feat: 添加缺失的图标文件(logo192.png, badge.png) 2025-11-04 17:46:53 +08:00
zdl
ffa6c2f761 pref: 优化 useEffect 依赖和清理逻辑 2025-11-04 16:01:56 +08:00
zdl
64a441b717 Merge branch 'feature_2025/1028_event' into feature_bugfix/251104_event
* feature_2025/1028_event:
  实现多选重要性,采用逗号分隔
2025-11-04 15:39:28 +08:00
zdl
5b9155a30c feat: 提取常量和 Hooks 到独立文件(已完成) 2025-11-04 15:38:54 +08:00
zdl
6e5eaa9089 feat: 添加serverworker注册事件 2025-11-04 15:34:17 +08:00
1ed54d7ee0 实现多选重要性,采用逗号分隔 2025-11-04 15:33:23 +08:00
zdl
8ed65b062b pref: 日志管理优化 2025-11-04 15:19:49 +08:00
zdl
868b4ccebc feat: 筛选添加收益率筛选 2025-11-04 15:19:24 +08:00
zdl
67981f21a2 feat:拆分 handlePageChange 为子函数(减少复杂度) 2025-11-04 15:05:25 +08:00
zdl
0a10270ab0 feat: 提取 usePagination Hook 2025-11-04 14:58:02 +08:00
124 changed files with 29284 additions and 4003 deletions

1470
CLAUDE.md

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

547
app.py
View File

@@ -706,11 +706,38 @@ class SubscriptionPlan(db.Model):
monthly_price = db.Column(db.Numeric(10, 2), nullable=False)
yearly_price = db.Column(db.Numeric(10, 2), nullable=False)
features = db.Column(db.Text, nullable=True)
pricing_options = db.Column(db.Text, nullable=True) # JSON格式[{"months": 1, "price": 99}, {"months": 12, "price": 999}]
is_active = db.Column(db.Boolean, default=True)
sort_order = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=beijing_now)
def to_dict(self):
# 解析pricing_options如果存在
pricing_opts = None
if self.pricing_options:
try:
pricing_opts = json.loads(self.pricing_options)
except:
pricing_opts = None
# 如果没有pricing_options则从monthly_price和yearly_price生成默认选项
if not pricing_opts:
pricing_opts = [
{
'months': 1,
'price': float(self.monthly_price) if self.monthly_price else 0,
'label': '月付',
'cycle_key': 'monthly'
},
{
'months': 12,
'price': float(self.yearly_price) if self.yearly_price else 0,
'label': '年付',
'cycle_key': 'yearly',
'discount_percent': 20 # 年付默认20%折扣
}
]
return {
'id': self.id,
'name': self.name,
@@ -718,6 +745,7 @@ class SubscriptionPlan(db.Model):
'description': self.description,
'monthly_price': float(self.monthly_price) if self.monthly_price else 0,
'yearly_price': float(self.yearly_price) if self.yearly_price else 0,
'pricing_options': pricing_opts, # 新增:灵活计费周期选项
'features': json.loads(self.features) if self.features else [],
'is_active': self.is_active,
'sort_order': self.sort_order
@@ -776,6 +804,10 @@ class PaymentOrder(db.Model):
'plan_name': self.plan_name,
'billing_cycle': self.billing_cycle,
'amount': float(self.amount) if self.amount else 0,
'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None,
'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0,
'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None,
'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False,
'qr_code_url': self.qr_code_url,
'status': self.status,
'is_expired': self.is_expired(),
@@ -786,6 +818,107 @@ class PaymentOrder(db.Model):
}
class PromoCode(db.Model):
"""优惠码表"""
__tablename__ = 'promo_codes'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
description = db.Column(db.String(200), nullable=True)
# 折扣类型和值
discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount'
discount_value = db.Column(db.Numeric(10, 2), nullable=False)
# 适用范围
applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式
applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式
min_amount = db.Column(db.Numeric(10, 2), nullable=True)
# 使用限制
max_uses = db.Column(db.Integer, nullable=True)
max_uses_per_user = db.Column(db.Integer, default=1)
current_uses = db.Column(db.Integer, default=0)
# 有效期
valid_from = db.Column(db.DateTime, nullable=False)
valid_until = db.Column(db.DateTime, nullable=False)
# 状态
is_active = db.Column(db.Boolean, default=True)
created_by = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
def to_dict(self):
return {
'id': self.id,
'code': self.code,
'description': self.description,
'discount_type': self.discount_type,
'discount_value': float(self.discount_value) if self.discount_value else 0,
'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None,
'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None,
'min_amount': float(self.min_amount) if self.min_amount else None,
'max_uses': self.max_uses,
'max_uses_per_user': self.max_uses_per_user,
'current_uses': self.current_uses,
'valid_from': self.valid_from.isoformat() if self.valid_from else None,
'valid_until': self.valid_until.isoformat() if self.valid_until else None,
'is_active': self.is_active
}
class PromoCodeUsage(db.Model):
"""优惠码使用记录表"""
__tablename__ = 'promo_code_usage'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False)
user_id = db.Column(db.Integer, nullable=False, index=True)
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
original_amount = db.Column(db.Numeric(10, 2), nullable=False)
discount_amount = db.Column(db.Numeric(10, 2), nullable=False)
final_amount = db.Column(db.Numeric(10, 2), nullable=False)
used_at = db.Column(db.DateTime, default=beijing_now)
# 关系
promo_code = db.relationship('PromoCode', backref='usages')
order = db.relationship('PaymentOrder', backref='promo_usage')
class SubscriptionUpgrade(db.Model):
"""订阅升级/降级记录表"""
__tablename__ = 'subscription_upgrades'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
user_id = db.Column(db.Integer, nullable=False, index=True)
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
# 原订阅信息
from_plan = db.Column(db.String(20), nullable=False)
from_cycle = db.Column(db.String(10), nullable=False)
from_end_date = db.Column(db.DateTime, nullable=True)
# 新订阅信息
to_plan = db.Column(db.String(20), nullable=False)
to_cycle = db.Column(db.String(10), nullable=False)
to_end_date = db.Column(db.DateTime, nullable=False)
# 价格计算
remaining_value = db.Column(db.Numeric(10, 2), nullable=False)
upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False)
actual_amount = db.Column(db.Numeric(10, 2), nullable=False)
upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both'
created_at = db.Column(db.DateTime, default=beijing_now)
# 关系
order = db.relationship('PaymentOrder', backref='upgrade_record')
# ============================================
# 模拟盘相关模型
# ============================================
@@ -982,8 +1115,15 @@ def get_user_subscription_safe(user_id):
return DefaultSub()
def activate_user_subscription(user_id, plan_type, billing_cycle):
"""激活用户订阅"""
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: 是否从当前时间开始延长(用于升级场景)
"""
try:
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
@@ -993,7 +1133,9 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
subscription.subscription_type = plan_type
subscription.subscription_status = 'active'
subscription.billing_cycle = billing_cycle
subscription.start_date = beijing_now()
if not extend_from_now or not subscription.start_date:
subscription.start_date = beijing_now()
if billing_cycle == 'monthly':
subscription.end_date = beijing_now() + timedelta(days=30)
@@ -1007,6 +1149,195 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
return None
def validate_promo_code(code, plan_name, billing_cycle, amount, user_id):
"""验证优惠码
Returns:
tuple: (promo_code_obj, error_message)
"""
try:
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
if not promo:
return None, "优惠码不存在或已失效"
# 检查有效期
now = beijing_now()
if now < promo.valid_from:
return None, "优惠码尚未生效"
if now > promo.valid_until:
return None, "优惠码已过期"
# 检查使用次数
if promo.max_uses and promo.current_uses >= promo.max_uses:
return None, "优惠码已被使用完"
# 检查每用户使用次数
if 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 plan_name not in applicable:
return None, "该优惠码不适用于此套餐"
except:
pass
# 检查适用周期
if promo.applicable_cycles:
try:
applicable = json.loads(promo.applicable_cycles)
if 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):
"""计算优惠金额"""
try:
if promo_code.discount_type == 'percentage':
discount = amount * (float(promo_code.discount_value) / 100)
else: # fixed_amount
discount = float(promo_code.discount_value)
# 确保折扣不超过总金额
return min(discount, amount)
except:
return 0
def calculate_remaining_value(subscription, current_plan):
"""计算当前订阅的剩余价值"""
try:
if not subscription or not subscription.end_date:
return 0
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):
"""计算升级所需价格
Returns:
dict: 包含价格计算结果的字典
"""
try:
# 1. 获取当前订阅
current_sub = UserSubscription.query.filter_by(user_id=user_id).first()
# 2. 获取目标套餐
to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first()
if not to_plan:
return {'error': '目标套餐不存在'}
# 3. 计算目标套餐价格
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
# 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 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
return result
# 5. 升级场景:计算剩余价值
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
if not current_plan:
return {'error': '当前套餐信息不存在'}
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'
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,
'discount_amount': 0,
'final_amount': upgrade_amount,
'promo_code': 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)
if promo:
discount = calculate_discount(promo, upgrade_amount)
result['discount_amount'] = discount
result['final_amount'] = upgrade_amount - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
except Exception as e:
return {'error': str(e)}
def initialize_subscription_plans_safe():
"""安全地初始化订阅套餐"""
try:
@@ -1112,16 +1443,24 @@ def get_subscription_plans():
'data': [plan.to_dict() for plan in plans]
})
except Exception as e:
# 返回默认套餐
# 返回默认套餐包含pricing_options以兼容新前端
default_plans = [
{
'id': 1,
'name': 'pro',
'display_name': 'Pro版本',
'description': '适合个人投资者的基础功能套餐',
'monthly_price': 0.01,
'yearly_price': 0.08,
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表'],
'monthly_price': 198,
'yearly_price': 2000,
'pricing_options': [
{'months': 1, 'price': 198, 'label': '月付', 'cycle_key': 'monthly'},
{'months': 3, 'price': 534, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
{'months': 6, 'price': 950, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
{'months': 12, 'price': 2000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 16},
{'months': 24, 'price': 3600, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 24},
{'months': 36, 'price': 5040, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 29}
],
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'],
'is_active': True,
'sort_order': 1
},
@@ -1130,9 +1469,17 @@ def get_subscription_plans():
'name': 'max',
'display_name': 'Max版本',
'description': '适合专业投资者的全功能套餐',
'monthly_price': 0.1,
'yearly_price': 0.8,
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送'],
'monthly_price': 998,
'yearly_price': 10000,
'pricing_options': [
{'months': 1, 'price': 998, 'label': '月付', 'cycle_key': 'monthly'},
{'months': 3, 'price': 2695, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
{'months': 6, 'price': 4790, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
{'months': 12, 'price': 10000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 17},
{'months': 24, 'price': 18000, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 25},
{'months': 36, 'price': 25200, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 30}
],
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送', 'API访问', '优先客服支持'],
'is_active': True,
'sort_order': 2
}
@@ -1189,9 +1536,90 @@ def get_subscription_info():
})
@app.route('/api/promo-code/validate', methods=['POST'])
def validate_promo_code_api():
"""验证优惠码"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
code = data.get('code', '').strip()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
amount = data.get('amount', 0)
if not code or not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 验证优惠码
promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id'])
if error:
return jsonify({
'success': False,
'valid': False,
'error': error
})
# 计算折扣
discount_amount = calculate_discount(promo, amount)
final_amount = amount - discount_amount
return jsonify({
'success': True,
'valid': True,
'promo_code': promo.to_dict(),
'discount_amount': discount_amount,
'final_amount': final_amount
})
except Exception as e:
return jsonify({
'success': False,
'error': f'验证失败: {str(e)}'
}), 500
@app.route('/api/subscription/calculate-price', methods=['POST'])
def calculate_subscription_price():
"""计算订阅价格(支持升级和优惠码)"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
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
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)
if 'error' in result:
return jsonify({
'success': False,
'error': result['error']
}), 400
return jsonify({
'success': True,
'data': result
})
except Exception as e:
return jsonify({
'success': False,
'error': f'计算失败: {str(e)}'
}), 500
@app.route('/api/payment/create-order', methods=['POST'])
def create_payment_order():
"""创建支付订单"""
"""创建支付订单(支持升级和优惠码)"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1199,23 +1627,21 @@ 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
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 获取套餐信息
try:
plan = SubscriptionPlan.query.filter_by(name=plan_name, is_active=True).first()
if not plan:
# 如果表不存在,使用默认价格
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
else:
amount = plan.monthly_price if billing_cycle == 'monthly' else plan.yearly_price
except:
# 默认价格
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
# 计算价格(包括升级和优惠码)
price_result = calculate_upgrade_price(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)
# 创建订单
try:
@@ -1225,10 +1651,52 @@ def create_payment_order():
billing_cycle=billing_cycle,
amount=amount
)
# 添加扩展字段(使用动态属性)
if hasattr(order, 'original_amount') or True: # 兼容性检查
order.original_amount = original_amount
order.discount_amount = discount_amount
order.is_upgrade = is_upgrade
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
order.promo_code_id = promo_obj.id
# 如果是升级,记录原套餐信息
if is_upgrade:
order.upgrade_from_plan = price_result.get('current_plan')
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:
return jsonify({'success': False, 'error': '订单创建失败'}), 500
db.session.rollback()
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
# 尝试调用真实的微信支付API
try:
@@ -1420,6 +1888,26 @@ def force_update_order_status(order_id):
# 激活用户订阅
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
# 记录优惠码使用(如果使用了优惠码)
if hasattr(order, 'promo_code_id') and order.promo_code_id:
try:
promo_usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount,
discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0,
final_amount=order.amount
)
db.session.add(promo_usage)
# 更新优惠码使用次数
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses = (promo.current_uses or 0) + 1
except Exception as e:
print(f"记录优惠码使用失败: {e}")
db.session.commit()
print(f"✅ 订单状态强制更新成功: {old_status} -> paid")
@@ -6599,8 +7087,15 @@ def api_get_events():
query = query.filter_by(status=event_status)
if event_type != 'all':
query = query.filter_by(event_type=event_type)
# 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A
if importance != 'all':
query = query.filter_by(importance=importance)
if ',' in importance:
# 多个重要性级别
importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()]
query = query.filter(Event.importance.in_(importance_list))
else:
# 单个重要性级别
query = query.filter_by(importance=importance)
if creator_id:
query = query.filter_by(creator_id=creator_id)
# 新增:行业代码过滤(申银万国行业分类)

5958
app_vx.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,15 +22,15 @@ openai_client = None
mysql_pool = None
# 配置
ES_HOST = 'http://192.168.1.58:9200'
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
ES_HOST = 'http://127.0.0.1:9200'
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
OPENAI_API_KEY = "dummy"
EMBEDDING_MODEL = "qwen3-embedding-8b"
INDEX_NAME = 'concept_library'
# MySQL配置
MYSQL_CONFIG = {
'host': '192.168.1.14',
'host': '192.168.1.8',
'user': 'root',
'password': 'Zzl5588161!',
'db': 'stock',
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
"field": "description_embedding",
"query_vector": embedding,
"k": k,
"num_candidates": min(k * 2, 500),
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k最大 10000
"boost": semantic_weight
}
}
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
"field": "description_embedding",
"query_vector": embedding,
"k": effective_search_size, # 使用有效搜索大小
"num_candidates": min(effective_search_size * 2, 1000)
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
},
"size": effective_search_size
}
@@ -1045,7 +1045,16 @@ async def get_concept_price_timeseries(
):
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
# 返回空时间序列而不是 503 错误
return PriceTimeSeriesResponse(
concept_id=concept_id,
concept_name=concept_id, # 无法查询名称,使用 ID
start_date=start_date,
end_date=end_date,
data_points=0,
timeseries=[]
)
if start_date > end_date:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
@@ -1150,11 +1159,93 @@ async def get_concept_statistics(
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
):
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
from datetime import datetime, timedelta
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
# 计算日期范围
if days is not None and (start_date is not None or end_date is not None):
pass # 参数冲突,但仍使用 days
if start_date is not None and end_date is not None:
pass # 使用提供的日期
elif days is not None:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
elif start_date is not None:
end_date = datetime.now().date()
elif end_date is not None:
start_date = end_date - timedelta(days=7)
else:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=7)
# 返回示例数据(与 except 块中相同)
fallback_statistics = ConceptStatistics(
hot_concepts=[
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
],
cold_concepts=[
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
],
active_concepts=[
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
],
volatile_concepts=[
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
],
momentum_concepts=[
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
],
summary={
'total_concepts': 500,
'positive_count': 320,
'negative_count': 180,
'avg_change': 1.8,
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'date_range': f"{start_date}{end_date}",
'days': (end_date - start_date).days + 1,
'start_date': str(start_date),
'end_date': str(end_date)
}
)
return ConceptStatisticsResponse(
success=True,
data=fallback_statistics,
params={
'days': (end_date - start_date).days + 1,
'min_stock_count': min_stock_count,
'start_date': str(start_date),
'end_date': str(end_date)
},
note="MySQL 连接不可用,使用示例数据"
)
try:
from datetime import datetime, timedelta
import random
# 参数验证和日期范围计算

View File

@@ -2,6 +2,9 @@ const path = require('path');
const webpack = require('webpack');
const { BundleAnalyzerPlugin } = process.env.ANALYZE ? require('webpack-bundle-analyzer') : { BundleAnalyzerPlugin: null };
// 检查是否为 Mock 模式(与 src/utils/apiConfig.js 保持一致)
const isMockMode = () => process.env.REACT_APP_ENABLE_MOCK === 'true';
module.exports = {
webpack: {
configure: (webpackConfig, { env, paths }) => {
@@ -27,7 +30,7 @@ module.exports = {
chunks: 'all',
maxInitialRequests: 30,
minSize: 20000,
maxSize: 244000, // 限制单个 chunk 最大大小(约 244KB
maxSize: 512000, // 限制单个 chunk 最大大小(512KB与 performance.maxAssetSize 一致
cacheGroups: {
// React 核心库单独分离
react: {
@@ -47,7 +50,7 @@ module.exports = {
chakraUI: {
test: /[\\/]node_modules[\\/](@chakra-ui|@emotion)[\\/]/,
name: 'chakra-ui',
priority: 22,
priority: 23, // 从 22 改为 23避免与 antd 优先级冲突
reuseExistingChunk: true,
},
// Ant Design
@@ -236,21 +239,38 @@ module.exports = {
devMiddleware: {
writeToDisk: false,
},
// 代理配置:将 /api 请求代理到后端服务器
proxy: {
'/api': {
target: 'http://49.232.185.254:5001',
changeOrigin: true,
secure: false,
logLevel: 'debug',
},
'/concept-api': {
target: 'http://49.232.185.254:6801',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
// 调试日志
onListening: (devServer) => {
console.log(`[CRACO] Mock Mode: ${isMockMode() ? 'Enabled ✅' : 'Disabled ❌'}`);
console.log(`[CRACO] Proxy: ${isMockMode() ? 'Disabled (MSW intercepts)' : 'Enabled (forwarding to backend)'}`);
},
// 代理配置:将 /api 请求代理到后端服务器
// 注意Mock 模式下禁用 proxy让 MSW 拦截请求
...(isMockMode() ? {} : {
proxy: {
'/api': {
target: 'http://49.232.185.254:5001',
changeOrigin: true,
secure: false,
logLevel: 'debug',
},
'/concept-api': {
target: 'http://49.232.185.254:6801',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
'/bytedesk-api': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/bytedesk-api': '' },
},
},
}),
},
};

View File

@@ -0,0 +1,59 @@
-- 数据库迁移:添加 pricing_options 字段,支持灵活的计费周期
-- 执行时间2025-01-XX
-- 说明:支持用户选择"包N个月"或"包N年"的套餐
-- 1. 添加 pricing_options 字段
ALTER TABLE subscription_plans
ADD COLUMN pricing_options TEXT NULL COMMENT 'JSON格式的计费周期选项';
-- 2. 为Pro套餐配置多种计费周期基于现有价格198元/月2000元/年)
UPDATE subscription_plans
SET pricing_options = '[
{"months": 1, "price": 198, "label": "月付", "cycle_key": "monthly"},
{"months": 3, "price": 534, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
{"months": 6, "price": 950, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
{"months": 12, "price": 2000, "label": "1年", "cycle_key": "yearly", "discount_percent": 16},
{"months": 24, "price": 3600, "label": "2年", "cycle_key": "2years", "discount_percent": 24},
{"months": 36, "price": 5040, "label": "3年", "cycle_key": "3years", "discount_percent": 29}
]'
WHERE name = 'pro';
-- 3. 为Max套餐配置多种计费周期基于现有价格998元/月10000元/年)
UPDATE subscription_plans
SET pricing_options = '[
{"months": 1, "price": 998, "label": "月付", "cycle_key": "monthly"},
{"months": 3, "price": 2695, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
{"months": 6, "price": 4790, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
{"months": 12, "price": 10000, "label": "1年", "cycle_key": "yearly", "discount_percent": 17},
{"months": 24, "price": 18000, "label": "2年", "cycle_key": "2years", "discount_percent": 25},
{"months": 36, "price": 25200, "label": "3年", "cycle_key": "3years", "discount_percent": 30}
]'
WHERE name = 'max';
-- ========================================
-- 价格计算说明
-- ========================================
-- Pro版198元/月2000元/年):
-- - 月付198元
-- - 3个月198×3×0.9 = 534元打9折省10%
-- - 半年198×6×0.8 = 950元打8折省20%
-- - 1年2000元已有年付价格约省16%
-- - 2年198×24×0.76 = 3600元省24%
-- - 3年198×36×0.7 = 5040元省30%
-- Max版998元/月10000元/年):
-- - 月付998元
-- - 3个月998×3×0.9 = 2695元打9折省10%
-- - 半年998×6×0.8 = 4790元打8折省20%
-- - 1年10000元已有年付价格约省17%
-- - 2年998×24×0.75 = 18000元省25%
-- - 3年998×36×0.7 = 25200元省30%
-- ========================================
-- 注意事项
-- ========================================
-- 1. 上述价格仅为示例,请根据实际营销策略调整
-- 2. 折扣力度建议:时间越长,优惠越大
-- 3. 如果不想提供某个周期,直接从数组中删除即可
-- 4. 前端会自动渲染所有可用的计费周期选项
-- 5. 用户可以通过优惠码获得额外折扣

381
docs/AGENT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,381 @@
# AI Agent 系统部署指南
## 🎯 系统架构
### 三阶段流程
```
用户输入
[阶段1: 计划制定 Planning]
- LLM 分析用户需求
- 确定需要哪些工具
- 制定执行计划steps
[阶段2: 工具执行 Execution]
- 按计划顺序调用 MCP 工具
- 收集数据
- 异常处理和重试
[阶段3: 结果总结 Summarization]
- LLM 综合分析所有数据
- 生成自然语言报告
输出给用户
```
## 📦 文件清单
### 后端文件
```
mcp_server.py # MCP 工具服务器(已有)
mcp_agent_system.py # Agent 系统核心逻辑(新增)
mcp_config.py # 配置文件(已有)
mcp_database.py # 数据库操作(已有)
```
### 前端文件
```
src/components/ChatBot/
├── ChatInterfaceV2.js # 新版聊天界面(漂亮)
├── PlanCard.js # 执行计划卡片
├── StepResultCard.js # 步骤结果卡片(可折叠)
├── ChatInterface.js # 旧版聊天界面(保留)
├── MessageBubble.js # 消息气泡组件(保留)
└── index.js # 统一导出
src/views/AgentChat/
└── index.js # Agent 聊天页面
```
## 🚀 部署步骤
### 1. 安装依赖
```bash
# 进入项目目录
cd /home/ubuntu/vf_react
# 安装 OpenAI SDK支持多个LLM提供商
pip install openai
```
### 2. 获取 LLM API Key
**推荐:通义千问(便宜且中文能力强)**
1. 访问 https://dashscope.console.aliyun.com/
2. 注册/登录阿里云账号
3. 开通 DashScope 服务
4. 创建 API Key
5. 复制 API Key格式`sk-xxx...`
**其他选择**
- DeepSeek: https://platform.deepseek.com/ (最便宜)
- OpenAI: https://platform.openai.com/ (需要翻墙)
### 3. 配置环境变量
```bash
# 编辑环境变量
sudo nano /etc/environment
# 添加以下内容(选择一个)
# 方式1: 通义千问(推荐)
DASHSCOPE_API_KEY="sk-your-key-here"
# 方式2: DeepSeek更便宜
DEEPSEEK_API_KEY="sk-your-key-here"
# 方式3: OpenAI
OPENAI_API_KEY="sk-your-key-here"
# 保存并退出,然后重新加载
source /etc/environment
# 验证环境变量
echo $DASHSCOPE_API_KEY
```
### 4. 修改 mcp_server.py
在文件末尾(`if __name__ == "__main__":` 之前)添加:
```python
# ==================== Agent 端点 ====================
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
# 创建 Agent 实例
agent = MCPAgent(provider="qwen") # 或 "deepseek", "openai"
@app.post("/agent/chat", response_model=AgentResponse)
async def agent_chat(request: ChatRequest):
"""智能代理对话端点"""
logger.info(f"Agent chat: {request.message}")
# 获取工具列表和处理器
tools = [tool.dict() for tool in TOOLS]
# 处理查询
response = await agent.process_query(
user_query=request.message,
tools=tools,
tool_handlers=TOOL_HANDLERS,
)
return response
```
### 5. 重启 MCP 服务
```bash
# 如果使用 systemd
sudo systemctl restart mcp-server
# 或者手动重启
pkill -f mcp_server
nohup uvicorn mcp_server:app --host 0.0.0.0 --port 8900 > mcp_server.log 2>&1 &
# 查看日志
tail -f mcp_server.log
```
### 6. 测试 Agent API
```bash
# 测试 Agent 端点
curl -X POST http://localhost:8900/agent/chat \
-H "Content-Type: application/json" \
-d '{
"message": "全面分析贵州茅台这只股票",
"conversation_history": []
}'
# 应该返回类似这样的JSON
# {
# "success": true,
# "message": "根据分析,贵州茅台...",
# "plan": {
# "goal": "全面分析贵州茅台",
# "steps": [...]
# },
# "step_results": [...],
# "metadata": {...}
# }
```
### 7. 部署前端
```bash
# 在本地构建
npm run build
# 上传到服务器
scp -r build/* ubuntu@your-server:/var/www/valuefrontier.cn/
# 或者在服务器上构建
cd /home/ubuntu/vf_react
npm run build
sudo cp -r build/* /var/www/valuefrontier.cn/
```
### 8. 重启 Nginx
```bash
sudo systemctl reload nginx
```
## ✅ 验证部署
### 1. 测试后端 API
```bash
# 测试工具列表
curl https://valuefrontier.cn/mcp/tools
# 测试 Agent
curl -X POST https://valuefrontier.cn/mcp/agent/chat \
-H "Content-Type: application/json" \
-d '{
"message": "今日涨停股票有哪些",
"conversation_history": []
}'
```
### 2. 测试前端
1. 访问 https://valuefrontier.cn/agent-chat
2. 输入问题:"全面分析贵州茅台这只股票"
3. 观察:
- ✓ 是否显示执行计划卡片
- ✓ 是否显示步骤执行过程
- ✓ 是否显示最终总结
- ✓ 步骤结果卡片是否可折叠
### 3. 测试用例
```
测试1: 简单查询
输入:查询贵州茅台的股票信息
预期:调用 get_stock_basic_info返回基本信息
测试2: 深度分析(推荐)
输入:全面分析贵州茅台这只股票
预期:
- 步骤1: get_stock_basic_info
- 步骤2: get_stock_financial_index
- 步骤3: get_stock_trade_data
- 步骤4: search_china_news
- 步骤5: summarize_with_llm
测试3: 市场热点
输入:今日涨停股票有哪些亮点
预期:
- 步骤1: search_limit_up_stocks
- 步骤2: get_concept_statistics
- 步骤3: summarize_with_llm
测试4: 概念分析
输入:新能源概念板块的投资机会
预期:
- 步骤1: search_concepts新能源
- 步骤2: search_china_news新能源
- 步骤3: summarize_with_llm
```
## 🐛 故障排查
### 问题1: Agent 返回 "Provider not configured"
**原因**: 环境变量未设置
**解决**:
```bash
# 检查环境变量
echo $DASHSCOPE_API_KEY
# 如果为空,重新设置
export DASHSCOPE_API_KEY="sk-xxx..."
# 重启服务
sudo systemctl restart mcp-server
```
### 问题2: Agent 返回 JSON 解析错误
**原因**: LLM 没有返回正确的 JSON 格式
**解决**: 在 `mcp_agent_system.py` 中已经处理了代码块标记清理,如果还有问题:
1. 检查 LLM 的 temperature 参数(建议 0.3
2. 检查 prompt 是否清晰
3. 尝试不同的 LLM 提供商
### 问题3: 前端显示 "查询失败"
**原因**: 后端 API 未正确配置或 Nginx 代理问题
**解决**:
```bash
# 1. 检查 MCP 服务是否运行
ps aux | grep mcp_server
# 2. 检查 Nginx 配置
sudo nginx -t
# 3. 查看错误日志
sudo tail -f /var/log/nginx/error.log
tail -f /home/ubuntu/vf_react/mcp_server.log
```
### 问题4: 执行步骤失败
**原因**: 某个 MCP 工具调用失败
**解决**: 查看步骤结果卡片中的错误信息,通常是:
- API 超时:增加 timeout
- 参数错误:检查工具定义
- 数据库连接失败:检查数据库连接
## 💰 成本估算
### 使用通义千问qwen-plus
**价格**: ¥0.004/1000 tokens
**典型对话消耗**:
- 简单查询1步: ~500 tokens = ¥0.002
- 深度分析5步: ~3000 tokens = ¥0.012
- 平均每次对话: ¥0.005
**月度成本**1000次深度分析:
- 1000次 × ¥0.012 = ¥12
**结论**: 非常便宜1000次深度分析只需要12元。
### 使用 DeepSeek更便宜
**价格**: ¥0.001/1000 tokens比通义千问便宜4倍
**月度成本**1000次深度分析:
- 1000次 × ¥0.003 = ¥3
## 📊 监控和优化
### 1. 添加日志监控
```bash
# 实时查看 Agent 日志
tail -f mcp_server.log | grep -E "\[Agent\]|\[Planning\]|\[Execution\]|\[Summary\]"
```
### 2. 性能优化建议
1. **缓存计划**: 相似的问题可以复用执行计划
2. **并行执行**: 独立的工具调用可以并行执行
3. **流式输出**: 使用 Server-Sent Events 实时返回进度
4. **结果缓存**: 相同的工具调用结果可以缓存
### 3. 添加统计分析
`mcp_server.py` 中添加:
```python
from datetime import datetime
import json
# 记录每次 Agent 调用
@app.post("/agent/chat")
async def agent_chat(request: ChatRequest):
start_time = datetime.now()
response = await agent.process_query(...)
duration = (datetime.now() - start_time).total_seconds()
# 记录到日志
logger.info(f"Agent query completed in {duration:.2f}s", extra={
"query": request.message,
"steps": len(response.plan.steps) if response.plan else 0,
"success": response.success,
"duration": duration,
})
return response
```
## 🎉 完成!
现在你的 AI Agent 系统已经部署完成!
访问 https://valuefrontier.cn/agent-chat 开始使用。
**特点**:
- ✅ 三阶段智能分析(计划-执行-总结)
- ✅ 漂亮的UI界面卡片式展示
- ✅ 步骤结果可折叠查看
- ✅ 实时进度反馈
- ✅ 异常处理和重试
- ✅ 成本低廉¥3-12/月)

1197
docs/Community.md Normal file

File diff suppressed because it is too large Load Diff

309
docs/MCP_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,309 @@
# MCP 架构说明
## 🎯 MCP 是什么?
**MCP (Model Context Protocol)** 是一个**工具调用协议**,它的核心职责是:
1.**定义工具接口**:告诉 LLM 有哪些工具可用,每个工具需要什么参数
2.**执行工具调用**:根据请求调用对应的后端 API
3.**返回结构化数据**:将 API 结果返回给调用方
**MCP 不负责**
- ❌ 自然语言理解NLU
- ❌ 意图识别
- ❌ 结果总结
- ❌ 对话管理
## 📊 当前架构
### 方案 1简单关键词匹配已实现
```
用户输入:"查询贵州茅台的股票信息"
前端 ChatInterface (关键词匹配)
MCP 工具层 (search_china_news)
返回 JSON 数据
前端显示原始数据
```
**问题**
- ✗ 只能识别简单关键词
- ✗ 无法理解复杂意图
- ✗ 返回的是原始 JSON用户体验差
### 方案 2集成 LLM推荐
```
用户输入:"查询贵州茅台的股票信息"
LLM (Claude/GPT-4/通义千问)
↓ 理解意图:需要查询股票代码 600519 的基本信息
↓ 选择工具get_stock_basic_info
↓ 提取参数:{"seccode": "600519"}
MCP 工具层
↓ 调用 API获取数据
返回结构化数据
LLM 总结结果
↓ "贵州茅台600519是中国知名的白酒生产企业
当前股价 1650.00 元,市值 2.07 万亿..."
前端显示自然语言回复
```
**优势**
- ✓ 理解复杂意图
- ✓ 自动选择合适的工具
- ✓ 自然语言总结,用户体验好
- ✓ 支持多轮对话
## 🔧 实现方案
### 选项 A前端集成 LLM快速实现
**适用场景**:快速原型、小规模应用
**优点**
- 实现简单
- 无需修改后端
**缺点**
- API Key 暴露在前端(安全风险)
- 每个用户都消耗 API 额度
- 无法统一管理和监控
**实现步骤**
1. 修改 `src/components/ChatBot/ChatInterface.js`
```javascript
import { llmService } from '../../services/llmService';
const handleSendMessage = async () => {
// ...
// 使用 LLM 服务替代简单的 mcpService.chat
const response = await llmService.chat(inputValue, messages);
// ...
};
```
2. 配置 API Key`.env.local`
```bash
REACT_APP_OPENAI_API_KEY=sk-xxx...
# 或者使用通义千问(更便宜)
REACT_APP_DASHSCOPE_API_KEY=sk-xxx...
```
### 选项 B后端集成 LLM生产推荐
**适用场景**:生产环境、需要安全和性能
**优点**
- ✓ API Key 安全(不暴露给前端)
- ✓ 统一管理和监控
- ✓ 可以做缓存优化
- ✓ 可以做速率限制
**缺点**
- 需要修改后端
- 增加服务器成本
**实现步骤**
#### 1. 安装依赖
```bash
pip install openai
```
#### 2. 修改 `mcp_server.py`,添加聊天端点
在文件末尾添加:
```python
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
# 创建聊天助手实例
chat_assistant = MCPChatAssistant(provider="qwen") # 推荐使用通义千问
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
"""智能对话端点 - 使用LLM理解意图并调用工具"""
logger.info(f"Chat request: {request.message}")
# 获取可用工具列表
tools = [tool.dict() for tool in TOOLS]
# 调用聊天助手
response = await chat_assistant.chat(
user_message=request.message,
conversation_history=request.conversation_history,
tools=tools,
)
return response
```
#### 3. 配置环境变量
在服务器上设置:
```bash
# 方式1使用通义千问推荐价格便宜
export DASHSCOPE_API_KEY="sk-xxx..."
# 方式2使用 OpenAI
export OPENAI_API_KEY="sk-xxx..."
# 方式3使用 DeepSeek最便宜
export DEEPSEEK_API_KEY="sk-xxx..."
```
#### 4. 修改前端 `mcpService.js`
```javascript
/**
* 智能对话 - 使用后端LLM处理
*/
async chat(userMessage, conversationHistory = []) {
try {
const response = await this.client.post('/chat', {
message: userMessage,
conversation_history: conversationHistory,
});
return {
success: true,
data: response,
};
} catch (error) {
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
```
#### 5. 修改前端 `ChatInterface.js`
```javascript
const handleSendMessage = async () => {
// ...
try {
// 调用后端聊天API
const response = await mcpService.chat(inputValue, messages);
if (response.success) {
const botMessage = {
id: Date.now() + 1,
content: response.data.message, // LLM总结的自然语言
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
toolUsed: response.data.tool_used, // 可选:显示使用了哪个工具
rawData: response.data.raw_data, // 可选:原始数据(折叠显示)
};
setMessages((prev) => [...prev, botMessage]);
}
} catch (error) {
// ...
}
};
```
## 💰 LLM 选择和成本
### 推荐:通义千问(阿里云)
**优点**
- 价格便宜1000次对话约 ¥1-2
- 中文理解能力强
- 国内访问稳定
**价格**
- qwen-plus: ¥0.004/1000 tokens约 ¥0.001/次对话)
- qwen-turbo: ¥0.002/1000 tokens更便宜
**获取 API Key**
1. 访问 https://dashscope.console.aliyun.com/
2. 创建 API Key
3. 设置环境变量 `DASHSCOPE_API_KEY`
### 其他选择
| 提供商 | 模型 | 价格 | 优点 | 缺点 |
|--------|------|------|------|------|
| **通义千问** | qwen-plus | ¥0.001/次 | 便宜、中文好 | - |
| **DeepSeek** | deepseek-chat | ¥0.0005/次 | 最便宜 | 新公司 |
| **OpenAI** | gpt-4o-mini | $0.15/1M tokens | 能力强 | 贵、需翻墙 |
| **Claude** | claude-3-haiku | $0.25/1M tokens | 理解力强 | 贵、需翻墙 |
## 🚀 部署步骤
### 1. 后端部署
```bash
# 安装依赖
pip install openai
# 设置 API Key
export DASHSCOPE_API_KEY="sk-xxx..."
# 重启服务
sudo systemctl restart mcp-server
# 测试聊天端点
curl -X POST https://valuefrontier.cn/mcp/chat \
-H "Content-Type: application/json" \
-d '{"message": "查询贵州茅台的股票信息"}'
```
### 2. 前端部署
```bash
# 构建
npm run build
# 部署
scp -r build/* user@server:/var/www/valuefrontier.cn/
```
### 3. 验证
访问 https://valuefrontier.cn/agent-chat测试对话
**测试用例**
1. "查询贵州茅台的股票信息" → 应返回自然语言总结
2. "今日涨停的股票有哪些" → 应返回涨停股票列表并总结
3. "新能源概念板块表现如何" → 应搜索概念并分析
## 📊 对比总结
| 特性 | 简单匹配 | 前端LLM | 后端LLM ⭐ |
|------|---------|---------|-----------|
| 实现难度 | 简单 | 中等 | 中等 |
| 用户体验 | 差 | 好 | 好 |
| 安全性 | 高 | 低 | 高 |
| 成本 | 无 | 用户承担 | 服务器承担 |
| 可维护性 | 差 | 中 | 好 |
| **推荐指数** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
## 🎯 最终推荐
**生产环境:后端集成 LLM (方案 B)**
- 使用通义千问qwen-plus
- 成本低(约 ¥50/月10000次对话
- 安全可靠
**快速原型:前端集成 LLM (方案 A)**
- 适合演示
- 快速验证可行性
- 后续再迁移到后端

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

108
mcp_config.py Normal file
View File

@@ -0,0 +1,108 @@
"""
MCP服务器配置文件
集中管理所有配置项
"""
from typing import Dict
from pydantic import BaseSettings
class Settings(BaseSettings):
"""应用配置"""
# 服务器配置
SERVER_HOST: str = "0.0.0.0"
SERVER_PORT: int = 8900
DEBUG: bool = True
# 后端API服务端点
NEWS_API_URL: str = "http://222.128.1.157:21891"
ROADSHOW_API_URL: str = "http://222.128.1.157:19800"
CONCEPT_API_URL: str = "http://222.128.1.157:16801"
STOCK_ANALYSIS_API_URL: str = "http://222.128.1.157:8811"
# HTTP客户端配置
HTTP_TIMEOUT: float = 60.0
HTTP_MAX_RETRIES: int = 3
# 日志配置
LOG_LEVEL: str = "INFO"
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# CORS配置
CORS_ORIGINS: list = ["*"]
CORS_CREDENTIALS: bool = True
CORS_METHODS: list = ["*"]
CORS_HEADERS: list = ["*"]
# LLM配置如果需要集成
LLM_PROVIDER: str = "openai" # openai, anthropic, etc.
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4"
LLM_BASE_URL: str = ""
# 速率限制
RATE_LIMIT_ENABLED: bool = False
RATE_LIMIT_PER_MINUTE: int = 60
# 缓存配置
CACHE_ENABLED: bool = True
CACHE_TTL: int = 300 # 秒
class Config:
env_file = ".env"
case_sensitive = True
# 全局设置实例
settings = Settings()
# 工具类别映射(用于组织和展示)
TOOL_CATEGORIES: Dict[str, list] = {
"新闻搜索": [
"search_news",
"search_china_news",
"search_medical_news"
],
"公司研究": [
"search_roadshows",
"search_research_reports"
],
"概念板块": [
"search_concepts",
"get_concept_details",
"get_stock_concepts",
"get_concept_statistics"
],
"股票分析": [
"search_limit_up_stocks",
"get_daily_stock_analysis"
]
}
# 工具优先级用于LLM选择工具时的提示
TOOL_PRIORITIES: Dict[str, int] = {
"search_china_news": 10, # 最常用
"search_concepts": 9,
"search_limit_up_stocks": 8,
"search_research_reports": 8,
"get_stock_concepts": 7,
"search_news": 6,
"get_daily_stock_analysis": 5,
"get_concept_statistics": 5,
"search_medical_news": 4,
"search_roadshows": 4,
"get_concept_details": 3,
}
# 默认参数配置
DEFAULT_PARAMS = {
"top_k": 20,
"page_size": 20,
"size": 10,
"sort_by": "change_pct",
"mode": "hybrid",
"exact_match": False,
}

783
mcp_database.py Normal file
View File

@@ -0,0 +1,783 @@
"""
MySQL数据库查询模块
提供股票财务数据查询功能
"""
import aiomysql
import logging
from typing import Dict, List, Any, Optional
from datetime import datetime, date
from decimal import Decimal
import json
logger = logging.getLogger(__name__)
# MySQL连接配置
MYSQL_CONFIG = {
'host': '222.128.1.157',
'port': 33060,
'user': 'root',
'password': 'Zzl5588161!',
'db': 'stock',
'charset': 'utf8mb4',
'autocommit': True
}
# 全局连接池
_pool = None
class DateTimeEncoder(json.JSONEncoder):
"""JSON编码器处理datetime和Decimal类型"""
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
async def get_pool():
"""获取MySQL连接池"""
global _pool
if _pool is None:
_pool = await aiomysql.create_pool(
host=MYSQL_CONFIG['host'],
port=MYSQL_CONFIG['port'],
user=MYSQL_CONFIG['user'],
password=MYSQL_CONFIG['password'],
db=MYSQL_CONFIG['db'],
charset=MYSQL_CONFIG['charset'],
autocommit=MYSQL_CONFIG['autocommit'],
minsize=1,
maxsize=10
)
logger.info("MySQL connection pool created")
return _pool
async def close_pool():
"""关闭MySQL连接池"""
global _pool
if _pool:
_pool.close()
await _pool.wait_closed()
_pool = None
logger.info("MySQL connection pool closed")
def convert_row(row: Dict) -> Dict:
"""转换数据库行,处理特殊类型"""
if not row:
return {}
result = {}
for key, value in row.items():
if isinstance(value, Decimal):
result[key] = float(value)
elif isinstance(value, (datetime, date)):
result[key] = value.isoformat()
else:
result[key] = value
return result
async def get_stock_basic_info(seccode: str) -> Optional[Dict[str, Any]]:
"""
获取股票基本信息
Args:
seccode: 股票代码
Returns:
股票基本信息字典
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ORGNAME,
F001V as english_name,
F003V as legal_representative,
F004V as registered_address,
F005V as office_address,
F010D as establishment_date,
F011V as website,
F012V as email,
F013V as phone,
F015V as main_business,
F016V as business_scope,
F017V as company_profile,
F030V as industry_level1,
F032V as industry_level2,
F034V as sw_industry_level1,
F036V as sw_industry_level2,
F026V as province,
F028V as city,
F041V as chairman,
F042V as general_manager,
UPDATE_DATE as update_date
FROM ea_baseinfo
WHERE SECCODE = %s
LIMIT 1
"""
await cursor.execute(query, (seccode,))
result = await cursor.fetchone()
if result:
return convert_row(result)
return None
async def get_stock_financial_index(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
获取股票财务指标
Args:
seccode: 股票代码
start_date: 开始日期 YYYY-MM-DD
end_date: 结束日期 YYYY-MM-DD
limit: 返回条数
Returns:
财务指标列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 构建查询
query = """
SELECT
SECCODE, SECNAME, ENDDATE, STARTDATE,
F069D as report_year,
F003N as eps, -- 每股收益
F004N as basic_eps,
F008N as bps, -- 每股净资产
F014N as roe, -- 净资产收益率
F016N as roa, -- 总资产报酬率
F017N as net_profit_margin, -- 净利润率
F022N as receivable_turnover, -- 应收账款周转率
F023N as inventory_turnover, -- 存货周转率
F025N as total_asset_turnover, -- 总资产周转率
F041N as debt_ratio, -- 资产负债率
F042N as current_ratio, -- 流动比率
F043N as quick_ratio, -- 速动比率
F052N as revenue_growth, -- 营业收入增长率
F053N as profit_growth, -- 净利润增长率
F089N as revenue, -- 营业收入
F090N as operating_cost, -- 营业成本
F101N as net_profit, -- 净利润
F102N as net_profit_parent -- 归母净利润
FROM ea_financialindex
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_trade_data(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 30
) -> List[Dict[str, Any]]:
"""
获取股票交易数据
Args:
seccode: 股票代码
start_date: 开始日期 YYYY-MM-DD
end_date: 结束日期 YYYY-MM-DD
limit: 返回条数
Returns:
交易数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, TRADEDATE,
F002N as prev_close, -- 昨日收盘价
F003N as open_price, -- 开盘价
F005N as high_price, -- 最高价
F006N as low_price, -- 最低价
F007N as close_price, -- 收盘价
F004N as volume, -- 成交量
F011N as turnover, -- 成交金额
F009N as change_amount, -- 涨跌额
F010N as change_pct, -- 涨跌幅
F012N as turnover_rate, -- 换手率
F013N as amplitude, -- 振幅
F026N as pe_ratio, -- 市盈率
F020N as total_shares, -- 总股本
F021N as circulating_shares -- 流通股本
FROM ea_trade
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND TRADEDATE >= %s"
params.append(start_date)
if end_date:
query += " AND TRADEDATE <= %s"
params.append(end_date)
query += " ORDER BY TRADEDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_balance_sheet(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 8
) -> List[Dict[str, Any]]:
"""
获取资产负债表数据
Args:
seccode: 股票代码
start_date: 开始日期
end_date: 结束日期
limit: 返回条数
Returns:
资产负债表数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ENDDATE,
F001D as report_year,
F006N as cash, -- 货币资金
F009N as receivables, -- 应收账款
F015N as inventory, -- 存货
F019N as current_assets, -- 流动资产合计
F023N as long_term_investment, -- 长期股权投资
F025N as fixed_assets, -- 固定资产
F037N as noncurrent_assets, -- 非流动资产合计
F038N as total_assets, -- 资产总计
F039N as short_term_loan, -- 短期借款
F042N as payables, -- 应付账款
F052N as current_liabilities, -- 流动负债合计
F053N as long_term_loan, -- 长期借款
F060N as noncurrent_liabilities, -- 非流动负债合计
F061N as total_liabilities, -- 负债合计
F062N as share_capital, -- 股本
F063N as capital_reserve, -- 资本公积
F065N as retained_earnings, -- 未分配利润
F070N as total_equity -- 所有者权益合计
FROM ea_asset
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_cashflow(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 8
) -> List[Dict[str, Any]]:
"""
获取现金流量表数据
Args:
seccode: 股票代码
start_date: 开始日期
end_date: 结束日期
limit: 返回条数
Returns:
现金流量表数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ENDDATE, STARTDATE,
F001D as report_year,
F009N as operating_cash_inflow, -- 经营活动现金流入
F014N as operating_cash_outflow, -- 经营活动现金流出
F015N as net_operating_cashflow, -- 经营活动现金流量净额
F021N as investing_cash_inflow, -- 投资活动现金流入
F026N as investing_cash_outflow, -- 投资活动现金流出
F027N as net_investing_cashflow, -- 投资活动现金流量净额
F031N as financing_cash_inflow, -- 筹资活动现金流入
F035N as financing_cash_outflow, -- 筹资活动现金流出
F036N as net_financing_cashflow, -- 筹资活动现金流量净额
F039N as net_cash_increase, -- 现金及现金等价物净增加额
F044N as net_profit, -- 净利润
F046N as depreciation, -- 固定资产折旧
F060N as net_operating_cashflow_adjusted -- 经营活动现金流量净额(补充)
FROM ea_cashflow
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def search_stocks_by_criteria(
industry: Optional[str] = None,
province: Optional[str] = None,
min_market_cap: Optional[float] = None,
max_market_cap: Optional[float] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
按条件搜索股票
Args:
industry: 行业名称
province: 省份
min_market_cap: 最小市值(亿元)
max_market_cap: 最大市值(亿元)
limit: 返回条数
Returns:
股票列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT DISTINCT
b.SECCODE,
b.SECNAME,
b.F030V as industry_level1,
b.F032V as industry_level2,
b.F034V as sw_industry_level1,
b.F026V as province,
b.F028V as city,
b.F015V as main_business,
t.F007N as latest_price,
t.F010N as change_pct,
t.F026N as pe_ratio,
t.TRADEDATE as latest_trade_date
FROM ea_baseinfo b
LEFT JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
GROUP BY SECCODE
) latest ON b.SECCODE = latest.SECCODE
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
AND t.TRADEDATE = latest.max_date
WHERE 1=1
"""
params = []
if industry:
query += " AND (b.F030V LIKE %s OR b.F032V LIKE %s OR b.F034V LIKE %s)"
pattern = f"%{industry}%"
params.extend([pattern, pattern, pattern])
if province:
query += " AND b.F026V = %s"
params.append(province)
if min_market_cap or max_market_cap:
# 市值 = 最新价 * 总股本 / 100000000转换为亿元
if min_market_cap:
query += " AND (t.F007N * t.F020N / 100000000) >= %s"
params.append(min_market_cap)
if max_market_cap:
query += " AND (t.F007N * t.F020N / 100000000) <= %s"
params.append(max_market_cap)
query += " ORDER BY t.TRADEDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_comparison(
seccodes: List[str],
metric: str = "financial"
) -> Dict[str, Any]:
"""
股票对比分析
Args:
seccodes: 股票代码列表
metric: 对比指标类型 (financial/trade)
Returns:
对比数据
"""
pool = await get_pool()
if not seccodes or len(seccodes) < 2:
return {"error": "至少需要2个股票代码进行对比"}
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
placeholders = ','.join(['%s'] * len(seccodes))
if metric == "financial":
# 对比最新财务指标
query = f"""
SELECT
f.SECCODE, f.SECNAME, f.ENDDATE,
f.F003N as eps,
f.F008N as bps,
f.F014N as roe,
f.F017N as net_profit_margin,
f.F041N as debt_ratio,
f.F052N as revenue_growth,
f.F053N as profit_growth,
f.F089N as revenue,
f.F101N as net_profit
FROM ea_financialindex f
INNER JOIN (
SELECT SECCODE, MAX(ENDDATE) as max_date
FROM ea_financialindex
WHERE SECCODE IN ({placeholders})
GROUP BY SECCODE
) latest ON f.SECCODE = latest.SECCODE
AND f.ENDDATE = latest.max_date
"""
else: # trade
# 对比最新交易数据
query = f"""
SELECT
t.SECCODE, t.SECNAME, t.TRADEDATE,
t.F007N as close_price,
t.F010N as change_pct,
t.F012N as turnover_rate,
t.F026N as pe_ratio,
t.F020N as total_shares,
t.F021N as circulating_shares
FROM ea_trade t
INNER JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
WHERE SECCODE IN ({placeholders})
GROUP BY SECCODE
) latest ON t.SECCODE = latest.SECCODE
AND t.TRADEDATE = latest.max_date
"""
await cursor.execute(query, seccodes)
results = await cursor.fetchall()
return {
"comparison_type": metric,
"stocks": [convert_row(row) for row in results]
}
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选股列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选股列表(包含最新行情数据)
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选股(假设有 user_favorites 表)
# 如果没有此表,可以根据实际情况调整
query = """
SELECT
f.user_id,
f.stock_code,
b.SECNAME as stock_name,
b.F030V as industry,
t.F007N as current_price,
t.F010N as change_pct,
t.F012N as turnover_rate,
t.F026N as pe_ratio,
t.TRADEDATE as latest_trade_date,
f.created_at as favorite_time
FROM user_favorites f
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
LEFT JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
GROUP BY SECCODE
) latest ON b.SECCODE = latest.SECCODE
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
AND t.TRADEDATE = latest.max_date
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY f.created_at DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选事件列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选事件列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选事件(假设有 user_event_favorites 表)
query = """
SELECT
f.user_id,
f.event_id,
e.title,
e.description,
e.event_date,
e.importance,
e.related_stocks,
e.category,
f.created_at as favorite_time
FROM user_event_favorites f
INNER JOIN events e ON f.event_id = e.id
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY e.event_date DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
添加自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_favorites
WHERE user_id = %s AND stock_code = %s
"""
await cursor.execute(check_query, [user_id, stock_code])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选股"}
else:
return {"success": False, "message": "该股票已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, stock_code])
return {"success": True, "message": "添加自选股成功"}
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
删除自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, stock_code])
if result > 0:
return {"success": True, "message": "删除自选股成功"}
else:
return {"success": False, "message": "未找到该自选股"}
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
添加自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_event_favorites
WHERE user_id = %s AND event_id = %s
"""
await cursor.execute(check_query, [user_id, event_id])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_event_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选事件"}
else:
return {"success": False, "message": "该事件已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, event_id])
return {"success": True, "message": "添加自选事件成功"}
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
删除自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_event_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, event_id])
if result > 0:
return {"success": True, "message": "删除自选事件成功"}
else:
return {"success": False, "message": "未找到该自选事件"}

320
mcp_elasticsearch.py Normal file
View File

@@ -0,0 +1,320 @@
"""
Elasticsearch 连接和工具模块
用于聊天记录存储和向量搜索
"""
from elasticsearch import Elasticsearch, helpers
from datetime import datetime
from typing import List, Dict, Any, Optional
import logging
import json
import openai
logger = logging.getLogger(__name__)
# ==================== 配置 ====================
# ES 配置
ES_CONFIG = {
"host": "http://222.128.1.157:19200",
"index_chat_history": "agent_chat_history", # 聊天记录索引
}
# Embedding 配置
EMBEDDING_CONFIG = {
"api_key": "dummy",
"base_url": "http://222.128.1.157:18008/v1",
"model": "qwen3-embedding-8b",
"dims": 4096, # 向量维度
}
# ==================== ES 客户端 ====================
class ESClient:
"""Elasticsearch 客户端封装"""
def __init__(self):
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
self.chat_index = ES_CONFIG["index_chat_history"]
# 初始化 OpenAI 客户端用于 embedding
self.embedding_client = openai.OpenAI(
api_key=EMBEDDING_CONFIG["api_key"],
base_url=EMBEDDING_CONFIG["base_url"],
)
self.embedding_model = EMBEDDING_CONFIG["model"]
# 初始化索引
self.create_chat_history_index()
def create_chat_history_index(self):
"""创建聊天记录索引"""
if self.es.indices.exists(index=self.chat_index):
logger.info(f"索引 {self.chat_index} 已存在")
return
mappings = {
"properties": {
"session_id": {"type": "keyword"}, # 会话ID
"user_id": {"type": "keyword"}, # 用户ID
"user_nickname": {"type": "text"}, # 用户昵称
"user_avatar": {"type": "keyword"}, # 用户头像URL
"message_type": {"type": "keyword"}, # user / assistant
"message": {"type": "text"}, # 消息内容
"message_embedding": { # 消息向量
"type": "dense_vector",
"dims": EMBEDDING_CONFIG["dims"],
"index": True,
"similarity": "cosine"
},
"plan": {"type": "text"}, # 执行计划(仅 assistant
"steps": {"type": "text"}, # 执行步骤(仅 assistant
"timestamp": {"type": "date"}, # 时间戳
"created_at": {"type": "date"}, # 创建时间
}
}
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
logger.info(f"创建索引: {self.chat_index}")
def generate_embedding(self, text: str) -> List[float]:
"""生成文本向量"""
try:
if not text or len(text.strip()) == 0:
return []
# 截断过长文本
text = text[:16000] if len(text) > 16000 else text
response = self.embedding_client.embeddings.create(
model=self.embedding_model,
input=[text]
)
return response.data[0].embedding
except Exception as e:
logger.error(f"Embedding 生成失败: {e}")
return []
def save_chat_message(
self,
session_id: str,
user_id: str,
user_nickname: str,
user_avatar: str,
message_type: str, # "user" or "assistant"
message: str,
plan: Optional[str] = None,
steps: Optional[str] = None,
) -> str:
"""
保存聊天消息
Args:
session_id: 会话ID
user_id: 用户ID
user_nickname: 用户昵称
user_avatar: 用户头像URL
message_type: 消息类型 (user/assistant)
message: 消息内容
plan: 执行计划(可选)
steps: 执行步骤(可选)
Returns:
文档ID
"""
try:
# 生成向量
embedding = self.generate_embedding(message)
doc = {
"session_id": session_id,
"user_id": user_id,
"user_nickname": user_nickname,
"user_avatar": user_avatar,
"message_type": message_type,
"message": message,
"message_embedding": embedding if embedding else None,
"plan": plan,
"steps": steps,
"timestamp": datetime.now(),
"created_at": datetime.now(),
}
result = self.es.index(index=self.chat_index, body=doc)
logger.info(f"保存聊天记录: {result['_id']}")
return result["_id"]
except Exception as e:
logger.error(f"保存聊天记录失败: {e}")
raise
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
"""
获取用户的聊天会话列表
Args:
user_id: 用户ID
limit: 返回数量
Returns:
会话列表每个会话包含session_id, last_message, last_timestamp
"""
try:
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
query = {
"query": {
"term": {"user_id": user_id}
},
"aggs": {
"sessions": {
"terms": {
"field": "session_id",
"size": limit,
"order": {"last_message": "desc"}
},
"aggs": {
"last_message": {
"max": {"field": "timestamp"}
},
"last_message_content": {
"top_hits": {
"size": 1,
"sort": [{"timestamp": {"order": "desc"}}],
"_source": ["message", "timestamp", "message_type"]
}
}
}
}
},
"size": 0
}
result = self.es.search(index=self.chat_index, body=query)
sessions = []
for bucket in result["aggregations"]["sessions"]["buckets"]:
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
sessions.append({
"session_id": bucket["key"],
"last_message": session_data["message"],
"last_timestamp": session_data["timestamp"],
"message_count": bucket["doc_count"],
})
return sessions
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
return []
def get_chat_history(
self,
session_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
获取指定会话的聊天历史
Args:
session_id: 会话ID
limit: 返回数量
Returns:
聊天记录列表
"""
try:
query = {
"query": {
"term": {"session_id": session_id}
},
"sort": [{"timestamp": {"order": "asc"}}],
"size": limit
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"message_type": doc["message_type"],
"message": doc["message"],
"plan": doc.get("plan"),
"steps": doc.get("steps"),
"timestamp": doc["timestamp"],
})
return messages
except Exception as e:
logger.error(f"获取聊天历史失败: {e}")
return []
def search_chat_history(
self,
user_id: str,
query_text: str,
top_k: int = 10
) -> List[Dict[str, Any]]:
"""
向量搜索聊天历史
Args:
user_id: 用户ID
query_text: 查询文本
top_k: 返回数量
Returns:
相关聊天记录列表
"""
try:
# 生成查询向量
query_embedding = self.generate_embedding(query_text)
if not query_embedding:
return []
# 向量搜索
query = {
"query": {
"bool": {
"must": [
{"term": {"user_id": user_id}},
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
"params": {"query_vector": query_embedding}
}
}
}
]
}
},
"size": top_k
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"session_id": doc["session_id"],
"message_type": doc["message_type"],
"message": doc["message"],
"timestamp": doc["timestamp"],
"score": hit["_score"],
})
return messages
except Exception as e:
logger.error(f"向量搜索失败: {e}")
return []
# ==================== 全局实例 ====================
# 创建全局 ES 客户端
es_client = ESClient()

2383
mcp_server.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
-- 数据库迁移脚本:添加优惠码和订阅升级相关表
-- 执行时间2025-xx-xx
-- 作者Claude Code
-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表
-- ============================================
-- 1. 创建优惠码表
-- ============================================
CREATE TABLE IF NOT EXISTS `promo_codes` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)',
`description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述',
-- 折扣类型和值
`discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage百分比 或 fixed_amount固定金额',
`discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值',
-- 适用范围
`applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐JSON格式如 ["pro", "max"]null表示全部适用',
`applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期JSON格式如 ["monthly", "yearly"]null表示全部适用',
`min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额',
-- 使用限制
`max_uses` INT DEFAULT NULL COMMENT '最大使用次数null表示无限制',
`max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数',
`current_uses` INT DEFAULT 0 COMMENT '当前已使用次数',
-- 有效期
`valid_from` DATETIME NOT NULL COMMENT '生效时间',
`valid_until` DATETIME NOT NULL COMMENT '失效时间',
-- 状态
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
`created_by` INT DEFAULT NULL COMMENT '创建人管理员ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_code (`code`),
INDEX idx_valid_dates (`valid_from`, `valid_until`),
INDEX idx_is_active (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
-- ============================================
-- 2. 创建优惠码使用记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `promo_code_usage` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`promo_code_id` INT NOT NULL COMMENT '优惠码ID',
`user_id` INT NOT NULL COMMENT '用户ID',
`order_id` INT NOT NULL COMMENT '订单ID',
`original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价',
`discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额',
`final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额',
`used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间',
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
INDEX idx_user_id (`user_id`),
INDEX idx_promo_code_id (`promo_code_id`),
INDEX idx_order_id (`order_id`),
INDEX idx_used_at (`used_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
-- ============================================
-- 3. 创建订阅升级记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `subscription_upgrades` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`order_id` INT NOT NULL COMMENT '订单ID',
-- 原订阅信息
`from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐',
`from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期',
`from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日',
-- 新订阅信息
`to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐',
`to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期',
`to_end_date` DATETIME NOT NULL COMMENT '新到期日',
-- 价格计算
`remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值',
`upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额',
`actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额',
`upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade套餐升级, cycle_change周期变更, both都变更',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
INDEX idx_user_id (`user_id`),
INDEX idx_order_id (`order_id`),
INDEX idx_created_at (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表';
-- ============================================
-- 4. 扩展 payment_orders 表(添加新字段)
-- ============================================
-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息
-- 如果字段已存在会报错,可以忽略
ALTER TABLE `payment_orders`
ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`,
ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`,
ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`,
ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`,
ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`;
-- 添加外键约束
ALTER TABLE `payment_orders`
ADD CONSTRAINT `fk_payment_orders_promo_code`
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL;
-- ============================================
-- 5. 插入示例优惠码(供测试使用)
-- ============================================
-- 10% 折扣优惠码,适用所有套餐和周期
INSERT INTO `promo_codes`
(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`)
VALUES
('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE);
-- 完成
SELECT 'Migration completed successfully!' AS status;

View File

@@ -18,10 +18,9 @@
"@fullcalendar/daygrid": "^5.9.0",
"@fullcalendar/interaction": "^5.9.0",
"@fullcalendar/react": "^5.9.0",
"@react-three/drei": "^9.11.3",
"@react-three/fiber": "^8.0.27",
"@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12",
"@tanstack/react-virtual": "^3.13.12",
"@tippyjs/react": "^4.2.6",
"@visx/visx": "^3.12.0",
"antd": "^5.27.4",
@@ -38,7 +37,6 @@
"fullcalendar": "^5.9.0",
"globalize": "^1.7.0",
"history": "^5.3.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.540.0",
"match-sorter": "6.3.0",
"moment": "^2.29.1",
@@ -58,7 +56,6 @@
"react-input-pin-code": "^1.1.5",
"react-just-parallax": "^3.1.16",
"react-jvectormap": "0.0.16",
"react-leaflet": "^3.2.5",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0-beta.4",
"react-redux": "^9.2.0",
@@ -81,7 +78,6 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"three": "^0.142.0",
"tsparticles-slim": "^2.12.0"
},
"resolutions": {
@@ -95,12 +91,14 @@
"scripts": {
"prestart": "kill-port 3000",
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
"prestart:real": "kill-port 3000",
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
"prestart:dev": "kill-port 3000",
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"start:test": "concurrently \"python app.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
"dev": "npm start",
"backend": "python app_2.py",
"backend": "python app.py",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",

BIN
public/badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -65,6 +65,9 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<!-- ============================================
Dify 机器人配置 - 只在 /home 页面显示
============================================ -->
<script>
window.difyChatbotConfig = {
token: 'DwN8qAKtYFQtWskM',
@@ -85,6 +88,44 @@
},
}
</script>
<!-- Dify 机器人显示控制脚本 -->
<script>
// 控制 Dify 机器人只在 /home 页面显示
function controlDifyVisibility() {
const currentPath = window.location.pathname;
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
if (difyChatButton) {
// 只在 /home 页面显示
if (currentPath === '/home') {
difyChatButton.style.display = 'none';
console.log('[Dify] 显示机器人(当前路径: /home');
} else {
difyChatButton.style.display = 'none';
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, '');
}
}
}
// 页面加载完成后执行
window.addEventListener('load', function() {
console.log('[Dify] 初始化显示控制');
// 初始检查(延迟执行,等待 Dify 按钮渲染)
setTimeout(controlDifyVisibility, 500);
setTimeout(controlDifyVisibility, 1500);
// 监听路由变化React Router 使用 pushState
const observer = setInterval(controlDifyVisibility, 1000);
// 清理函数(可选)
window.addEventListener('beforeunload', function() {
clearInterval(observer);
});
});
</script>
<script
src="https://app.valuefrontier.cn/embed.min.js"
id="DwN8qAKtYFQtWskM"
@@ -166,7 +207,7 @@
bottom: 80px !important;
left: 10px !important;
}
#dify-chatbot-bubble-button {
width: 56px !important;
height: 56px !important;

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -4,8 +4,24 @@
"icons": [
{
"src": "favicon.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"sizes": "32x32",
"type": "image/png"
},
{
"src": "badge.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "badge"
},
{
"src": "logo192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "apple-icon.png",
"sizes": "32x32",
"type": "image/png"
}
],
"start_url": "./index.html",

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.6'
const PACKAGE_VERSION = '2.12.0'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

92
public/service-worker.js Normal file
View File

@@ -0,0 +1,92 @@
// public/service-worker.js
/**
* Service Worker for Browser Notifications
* 主要功能:支持浏览器通知的稳定运行
*/
const CACHE_NAME = 'valuefrontier-v1';
// Service Worker 安装事件
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
// 跳过等待,立即激活
self.skipWaiting();
});
// Service Worker 激活事件
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
// 立即接管所有页面
event.waitUntil(self.clients.claim());
});
// 通知点击事件
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] Notification clicked:', event.notification.tag);
event.notification.close();
// 获取通知数据中的链接
const urlToOpen = event.notification.data?.link;
if (urlToOpen) {
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
// 查找是否已有打开的窗口
for (let client of windowClients) {
if (client.url.includes(window.location.origin) && 'focus' in client) {
// 聚焦现有窗口并导航到目标页面
return client.focus().then(client => {
return client.navigate(urlToOpen);
});
}
}
// 如果没有打开的窗口,打开新窗口
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
}
});
// 通知关闭事件
self.addEventListener('notificationclose', (event) => {
console.log('[Service Worker] Notification closed:', event.notification.tag);
});
// Fetch 事件 - 基础的网络优先策略
self.addEventListener('fetch', (event) => {
// 对于通知相关的资源,使用网络优先策略
event.respondWith(
fetch(event.request)
.catch(() => {
// 网络失败时,尝试从缓存获取
return caches.match(event.request);
})
);
});
// 推送消息事件(预留,用于未来的 Push API 集成)
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push message received:', event);
if (event.data) {
const data = event.data.json();
const options = {
body: data.body || '您有新消息',
icon: data.icon || '/favicon.png',
badge: '/favicon.png',
data: data.data || {},
requireInteraction: data.requireInteraction || false,
tag: data.tag || `notification_${Date.now()}`,
};
event.waitUntil(
self.registration.showNotification(data.title || '价值前沿', options)
);
}
});
console.log('[Service Worker] Loaded successfully');

View File

@@ -0,0 +1,156 @@
################################################################################
# Bytedesk客服系统环境变量配置示例
#
# 使用方法:
# 1. 复制本文件到vf_react项目根目录与package.json同级
# cp bytedesk-integration/.env.bytedesk.example .env.local
#
# 2. 根据实际部署环境修改配置值
#
# 3. 重启开发服务器使配置生效
# npm start
#
# 注意事项:
# - .env.local文件不应提交到Git已在.gitignore中
# - 开发环境和生产环境应使用不同的配置文件
# - 所有以REACT_APP_开头的变量会被打包到前端代码中
################################################################################
# ============================================================================
# Bytedesk服务器配置必需
# ============================================================================
# Bytedesk后端服务地址生产环境
# 格式: http://IP地址 或 https://域名
# 示例: http://43.143.189.195 或 https://kefu.yourdomain.com
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# ============================================================================
# Bytedesk组织和工作组配置必需
# ============================================================================
# 组织IDOrganization UID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 复制UID
# 示例: df_org_uid
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组IDWorkgroup SID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 复制工作组ID
# 示例: df_wg_aftersales (售后服务组)
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ============================================================================
# 可选配置
# ============================================================================
# 客服类型
# 2 = 人工客服(默认)
# 1 = 机器人客服
# REACT_APP_BYTEDESK_TYPE=2
# 语言设置
# zh-cn = 简体中文(默认)
# en = 英语
# ja = 日语
# ko = 韩语
# REACT_APP_BYTEDESK_LOCALE=zh-cn
# 客服图标位置
# bottom-right = 右下角(默认)
# bottom-left = 左下角
# top-right = 右上角
# top-left = 左上角
# REACT_APP_BYTEDESK_PLACEMENT=bottom-right
# 客服图标边距(像素)
# REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
# REACT_APP_BYTEDESK_MARGIN_SIDE=20
# 主题模式
# system = 跟随系统(默认)
# light = 亮色模式
# dark = 暗色模式
# REACT_APP_BYTEDESK_THEME_MODE=system
# 主题色(十六进制颜色)
# REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
# 是否自动弹出客服窗口(不推荐)
# true = 页面加载后自动弹出
# false = 需用户点击图标弹出(默认)
# REACT_APP_BYTEDESK_AUTO_POPUP=false
# ============================================================================
# 开发环境专用配置
# ============================================================================
# 开发环境可以使用不同的服务器地址
# 取消注释以下行使用本地或测试服务器
# REACT_APP_BYTEDESK_API_URL_DEV=http://localhost:9003
# ============================================================================
# 配置示例 - 不同部署场景
# ============================================================================
# ---------- 示例1: 生产环境(域名访问) ----------
# REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
# REACT_APP_BYTEDESK_ORG=prod_org_12345
# REACT_APP_BYTEDESK_SID=prod_wg_sales
# ---------- 示例2: 测试环境IP访问 ----------
# REACT_APP_BYTEDESK_API_URL=http://192.168.1.100
# REACT_APP_BYTEDESK_ORG=test_org_abc
# REACT_APP_BYTEDESK_SID=test_wg_support
# ---------- 示例3: 本地开发环境 ----------
# REACT_APP_BYTEDESK_API_URL=http://localhost:9003
# REACT_APP_BYTEDESK_ORG=dev_org_local
# REACT_APP_BYTEDESK_SID=dev_wg_test
# ============================================================================
# 故障排查
# ============================================================================
# 问题1: 客服图标不显示
# 解决方案:
# - 检查REACT_APP_BYTEDESK_API_URL是否可访问
# - 确认.env文件在项目根目录
# - 重启开发服务器npm start
# - 查看浏览器控制台是否有错误
# 问题2: 连接不上后端服务
# 解决方案:
# - 确认后端服务已启动docker ps查看bytedesk-prod容器
# - 检查CORS配置后端.env.production中的BYTEDESK_CORS_ALLOWED_ORIGINS
# - 确认防火墙未阻止80/443端口
# 问题3: ORG或SID配置错误
# 解决方案:
# - 登录管理后台http://43.143.189.195/admin/
# - 导航到"设置" -> "组织信息"获取ORG
# - 导航到"客服管理" -> "工作组"获取SID
# - 确保复制的ID没有多余空格
# 问题4: 多工作组场景
# 解决方案:
# - 可以为不同页面配置不同的SID
# - 在bytedesk.config.js中使用条件判断
# - 示例: 售后页面用售后组SID销售页面用销售组SID
# ============================================================================
# 安全提示
# ============================================================================
# 1. 不要在代码中硬编码API地址和ID
# 2. .env.local文件不应提交到Git仓库
# 3. 生产环境建议使用HTTPS
# 4. 定期更新后端服务器的安全补丁
# 5. 不要在公开的代码库中暴露组织ID和工作组ID
# ============================================================================
# 更多信息
# ============================================================================
# Bytedesk官方文档: https://docs.bytedesk.com
# 技术支持: 访问http://43.143.189.195/chat/联系在线客服
# GitHub: https://github.com/Bytedesk/bytedesk

View File

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

View File

@@ -0,0 +1,140 @@
/**
* Bytedesk客服Widget组件
* 用于vf_react项目集成
*
* 使用方法:
* import BytedeskWidget from './components/BytedeskWidget';
* import { getBytedeskConfig } from './config/bytedesk.config';
*
* <BytedeskWidget
* config={getBytedeskConfig()}
* autoLoad={true}
* />
*/
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
const BytedeskWidget = ({
config,
autoLoad = true,
onLoad,
onError
}) => {
const scriptRef = useRef(null);
const widgetRef = useRef(null);
useEffect(() => {
// 如果不自动加载或配置未设置,跳过
if (!autoLoad || !config) {
if (!config) {
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
}
return;
}
console.log('[Bytedesk] 开始加载客服Widget...', config);
// 加载Bytedesk Widget脚本
const script = document.createElement('script');
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
script.async = true;
script.id = 'bytedesk-web-script';
script.onload = () => {
console.log('[Bytedesk] Widget脚本加载成功');
try {
if (window.BytedeskWeb) {
console.log('[Bytedesk] 初始化Widget');
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
widgetRef.current = bytedesk;
console.log('[Bytedesk] Widget初始化成功');
if (onLoad) {
onLoad(bytedesk);
}
} else {
throw new Error('BytedeskWeb对象未定义');
}
} catch (error) {
console.error('[Bytedesk] Widget初始化失败:', error);
if (onError) {
onError(error);
}
}
};
script.onerror = (error) => {
console.error('[Bytedesk] Widget脚本加载失败:', error);
if (onError) {
onError(error);
}
};
// 添加脚本到页面
document.body.appendChild(script);
scriptRef.current = script;
// 清理函数
return () => {
console.log('[Bytedesk] 清理Widget');
// 移除脚本
if (scriptRef.current && document.body.contains(scriptRef.current)) {
document.body.removeChild(scriptRef.current);
}
// 移除Widget DOM元素
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
widgetElements.forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
// 清理全局对象
if (window.BytedeskWeb) {
delete window.BytedeskWeb;
}
};
}, [config, autoLoad, onLoad, onError]);
// 不渲染任何可见元素Widget会自动插入到body
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
};
BytedeskWidget.propTypes = {
config: PropTypes.shape({
apiUrl: PropTypes.string.isRequired,
htmlUrl: PropTypes.string.isRequired,
placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']),
marginBottom: PropTypes.number,
marginSide: PropTypes.number,
autoPopup: PropTypes.bool,
locale: PropTypes.string,
bubbleConfig: PropTypes.shape({
show: PropTypes.bool,
icon: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
}),
theme: PropTypes.shape({
mode: PropTypes.oneOf(['light', 'dark', 'system']),
backgroundColor: PropTypes.string,
textColor: PropTypes.string,
}),
chatConfig: PropTypes.shape({
org: PropTypes.string.isRequired,
t: PropTypes.string.isRequired,
sid: PropTypes.string.isRequired,
}).isRequired,
}),
autoLoad: PropTypes.bool,
onLoad: PropTypes.func,
onError: PropTypes.func,
};
export default BytedeskWidget;

View File

@@ -0,0 +1,148 @@
/**
* Bytedesk客服配置文件
* 指向43.143.189.195服务器
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_aftersales
*/
// 从环境变量读取配置
const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_aftersales';
/**
* Bytedesk客服基础配置
*/
export const bytedeskConfig = {
// API服务地址
apiUrl: BYTEDESK_API_URL,
// 聊天页面地址
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
// SDK 资源基础路径(用于加载内部模块 sdk.js, index.js 等)
baseUrl: 'https://www.weiyuai.cn',
// 客服图标位置
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
// 边距设置(像素)
marginBottom: 20,
marginSide: 20,
// 自动弹出(不推荐)
autoPopup: false,
// 语言设置
locale: 'zh-cn', // zh-cn | en | ja | ko
// 客服图标配置
bubbleConfig: {
show: true, // 是否显示客服图标
icon: '💬', // 图标emoji或图片URL
title: '在线客服', // 鼠标悬停标题
subtitle: '点击咨询', // 副标题
},
// 主题配置
theme: {
mode: 'system', // light | dark | system
backgroundColor: '#0066FF', // 主题色
textColor: '#ffffff', // 文字颜色
},
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
t: '2', // 类型: 2=客服, 1=机器人
sid: BYTEDESK_SID, // 工作组ID
},
};
/**
* 获取Bytedesk配置根据环境自动切换
*
* @returns {Object} Bytedesk配置对象
*/
export const getBytedeskConfig = () => {
// 所有环境都使用公网地址(不使用代理)
return bytedeskConfig;
};
/**
* 获取带用户信息的配置
* 用于已登录用户,自动传递用户信息到客服端
*
* @param {Object} user - 用户对象
* @param {string} user.id - 用户ID
* @param {string} user.name - 用户名
* @param {string} user.email - 用户邮箱
* @param {string} user.mobile - 用户手机号
* @returns {Object} 带用户信息的Bytedesk配置
*/
export const getBytedeskConfigWithUser = (user) => {
const config = getBytedeskConfig();
if (user && user.id) {
return {
...config,
chatConfig: {
...config.chatConfig,
// 传递用户信息(可选)
customParams: {
userId: user.id,
userName: user.name || 'Guest',
userEmail: user.email || '',
userMobile: user.mobile || '',
source: 'web', // 来源标识
},
},
};
}
return config;
};
/**
* 根据页面路径判断是否显示客服
*
* @param {string} pathname - 当前页面路径
* @returns {boolean} 是否显示客服
*/
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
];
// 检查是否在黑名单
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
// 默认所有页面都显示客服
return true;
/* ============================================
白名单模式(备用,需要时取消注释)
============================================
const allowedPages = [
'/', // 首页
'/home', // 主页
'/products', // 产品页
'/pricing', // 价格页
'/contact', // 联系我们
];
// 只在白名单页面显示客服
return allowedPages.some(page => pathname.startsWith(page));
============================================ */
};
export default {
bytedeskConfig,
getBytedeskConfig,
getBytedeskConfigWithUser,
shouldShowCustomerService,
};

View File

@@ -508,19 +508,19 @@ export default function WechatRegister() {
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
scrolling="no"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
allow="clipboard-write"
style={{
border: 'none',
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transform: 'scale(0.77) translateY(-35px)',
transformOrigin: 'top left',
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
pointerEvents: 'auto',
overflow: 'hidden',
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
onWheel={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -0,0 +1,376 @@
// src/components/ChatBot/ChatInterface.js
// 聊天界面主组件
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Menu,
MenuButton,
MenuList,
MenuItem,
Button,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
import { ChevronDownIcon } from '@chakra-ui/icons';
import MessageBubble from './MessageBubble';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* 聊天界面组件
*/
export const ChatInterface = () => {
const [messages, setMessages] = useState([
{
id: 1,
content: '你好我是AI投资助手我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [availableTools, setAvailableTools] = useState([]);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const inputBg = useColorModeValue('gray.50', 'gray.700');
// 加载可用工具列表
useEffect(() => {
const loadTools = async () => {
const result = await mcpService.listTools();
if (result.success) {
setAvailableTools(result.data);
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
}
};
loadTools();
}, []);
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage = {
id: Date.now(),
content: inputValue,
isUser: true,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
try {
// 调用MCP服务
const response = await mcpService.chat(inputValue, messages);
let botMessage;
if (response.success) {
// 根据返回的数据类型构造消息
const data = response.data;
if (typeof data === 'string') {
botMessage = {
id: Date.now() + 1,
content: data,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
} else if (Array.isArray(data)) {
// 数据列表
botMessage = {
id: Date.now() + 1,
content: `找到 ${data.length} 条结果:`,
isUser: false,
type: 'data',
data: data,
timestamp: new Date().toISOString(),
};
} else if (typeof data === 'object') {
// 对象数据
botMessage = {
id: Date.now() + 1,
content: JSON.stringify(data, null, 2),
isUser: false,
type: 'markdown',
timestamp: new Date().toISOString(),
};
} else {
botMessage = {
id: Date.now() + 1,
content: '抱歉,我无法理解这个查询结果。',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
} else {
botMessage = {
id: Date.now() + 1,
content: `抱歉,查询失败:${response.error}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
logger.error('ChatInterface', 'handleSendMessage', error);
const errorMessage = {
id: Date.now() + 1,
content: `抱歉,发生了错误:${error.message}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
content: '对话已清空。有什么可以帮到你的?',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
};
// 复制消息
const handleCopyMessage = () => {
toast({
title: '已复制',
status: 'success',
duration: 2000,
isClosable: true,
});
};
// 反馈
const handleFeedback = (type) => {
logger.info('ChatInterface', 'Feedback', { type });
toast({
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
status: 'info',
duration: 2000,
isClosable: true,
});
};
// 快捷问题
const quickQuestions = [
'查询贵州茅台的股票信息',
'搜索人工智能相关新闻',
'今日涨停股票有哪些',
'新能源概念板块分析',
];
const handleQuickQuestion = (question) => {
setInputValue(question);
inputRef.current?.focus();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部工具栏 */}
<Flex
px={4}
py={3}
borderBottom="1px"
borderColor={borderColor}
align="center"
justify="space-between"
>
<HStack>
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
<Badge colorScheme="green">在线</Badge>
{availableTools.length > 0 && (
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
)}
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
<Menu>
<MenuButton
as={IconButton}
icon={<FiSettings />}
size="sm"
variant="ghost"
aria-label="设置"
/>
<MenuList>
<MenuItem>模型设置</MenuItem>
<MenuItem>快捷指令</MenuItem>
<MenuItem>历史记录</MenuItem>
</MenuList>
</Menu>
</HStack>
</Flex>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={4}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={0} align="stretch">
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isUser={message.isUser}
onCopy={handleCopyMessage}
onFeedback={handleFeedback}
/>
))}
{isLoading && (
<Flex justify="flex-start" mb={4}>
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
<Spinner size="sm" mr={2} />
<Text fontSize="sm">AI正在思考...</Text>
</Flex>
</Flex>
)}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题(仅在消息较少时显示) */}
{messages.length <= 2 && (
<Box px={4} py={2}>
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="xs"
variant="outline"
onClick={() => handleQuickQuestion(question)}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
<Divider />
{/* 输入框 */}
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入消息... (Shift+Enter换行Enter发送)"
bg={inputBg}
border="none"
_focus={{ boxShadow: 'none' }}
mr={2}
disabled={isLoading}
/>
<IconButton
icon={<FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isLoading}
disabled={!inputValue.trim()}
/>
</Flex>
</Box>
</Flex>
);
};
export default ChatInterface;

View File

@@ -0,0 +1,681 @@
// src/components/ChatBot/ChatInterfaceV2.js
// 重新设计的聊天界面 - 更漂亮、支持Agent模式
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Button,
Avatar,
Heading,
Progress,
Fade,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiDownload, FiCpu, FiUser, FiZap } from 'react-icons/fi';
import { PlanCard } from './PlanCard';
import { StepResultCard } from './StepResultCard';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* 聊天界面V2组件 - Agent模式
*/
export const ChatInterfaceV2 = () => {
const [messages, setMessages] = useState([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '你好我是AI投资研究助手。我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 更新最后一条消息
const updateLastMessage = (updates) => {
setMessages((prev) => {
const newMessages = [...prev];
if (newMessages.length > 0) {
newMessages[newMessages.length - 1] = {
...newMessages[newMessages.length - 1],
...updates,
};
}
return newMessages;
});
};
// 发送消息Agent模式 - 流式)
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue; // 保存输入值
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
// 用于存储步骤结果
let currentPlan = null;
let stepResults = [];
let executingMessageId = null;
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 使用 EventSource 接收流式数据
const eventSource = new EventSource(
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
}
);
// 由于 EventSource 不支持 POST我们使用 fetch + ReadableStream
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
});
if (!response.ok) {
throw new Error('Agent请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop(); // 保留不完整的行
for (const line of lines) {
if (!line.trim()) continue;
// 解析 SSE 消息
const eventMatch = line.match(/^event: (.+)$/m);
const dataMatch = line.match(/^data: (.+)$/m);
if (!eventMatch || !dataMatch) continue;
const event = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
logger.info(`SSE Event: ${event}`, data);
// 处理不同类型的事件
switch (event) {
case 'status':
if (data.stage === 'planning') {
// 移除思考消息,显示规划中
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
type: MessageTypes.AGENT_THINKING,
content: data.message,
timestamp: new Date().toISOString(),
});
setCurrentProgress(20);
} else if (data.stage === 'executing') {
setCurrentProgress(30);
} else if (data.stage === 'summarizing') {
setCurrentProgress(90);
}
break;
case 'plan':
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 显示执行计划
currentPlan = data;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
break;
case 'step_start':
// 如果还没有执行中消息,创建一个
if (!executingMessageId) {
const executingMsg = {
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: [],
timestamp: new Date().toISOString(),
};
addMessage(executingMsg);
executingMessageId = Date.now();
}
break;
case 'step_complete':
// 添加步骤结果
stepResults.push({
step_index: data.step_index,
tool: data.tool,
status: data.status,
result: data.result,
error: data.error,
execution_time: data.execution_time,
arguments: data.arguments,
});
// 更新执行中消息
setMessages(prev =>
prev.map(msg =>
msg.type === MessageTypes.AGENT_EXECUTING
? { ...msg, stepResults: [...stepResults] }
: msg
)
);
// 更新进度
if (currentPlan) {
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
setCurrentProgress(progress);
}
break;
case 'summary':
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.content,
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
break;
case 'error':
throw new Error(data.message);
case 'done':
logger.info('Stream完成');
break;
default:
logger.warn('未知事件类型:', event);
}
}
}
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages(prev => prev.filter(
m => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
));
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${error.message}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '对话已清空。有什么可以帮到你的?',
timestamp: new Date().toISOString(),
},
]);
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : 'AI助手'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部 */}
<Box
bg={chatBg}
borderBottom="1px"
borderColor={borderColor}
px={6}
py={4}
>
<HStack justify="space-between">
<HStack spacing={4}>
<Avatar
size="md"
bg="blue.500"
icon={<FiCpu fontSize="1.5rem" />}
/>
<VStack align="start" spacing={0}>
<Heading size="md">AI投资研究助手</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>💡 试试这些问题</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" bg="blue.500" icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex={1}>
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex={1} spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex={1} spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default ChatInterfaceV2;

View File

@@ -0,0 +1,72 @@
// src/components/ChatBot/EChartsRenderer.js
// ECharts 图表渲染组件
import React, { useEffect, useRef } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts';
/**
* ECharts 图表渲染组件
* @param {Object} option - ECharts 配置对象
* @param {number} height - 图表高度(默认 400px
*/
export const EChartsRenderer = ({ option, height = 400 }) => {
const chartRef = useRef(null);
const chartInstance = useRef(null);
const bgColor = useColorModeValue('white', 'gray.800');
useEffect(() => {
if (!chartRef.current || !option) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
// 设置默认主题配置
const defaultOption = {
backgroundColor: 'transparent',
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...option,
};
// 设置图表配置
chartInstance.current.setOption(defaultOption, true);
// 响应式调整大小
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
};
}, [option]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
return (
<Box
ref={chartRef}
width="100%"
height={`${height}px`}
bg={bgColor}
borderRadius="md"
boxShadow="sm"
/>
);
};

View File

@@ -0,0 +1,189 @@
// src/components/ChatBot/MarkdownWithCharts.js
// 支持 ECharts 图表的 Markdown 渲染组件
import React from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import { EChartsRenderer } from './EChartsRenderer';
import { logger } from '@utils/logger';
/**
* 解析 Markdown 内容,提取 ECharts 代码块
* @param {string} markdown - Markdown 文本
* @returns {Array} - 包含文本和图表的数组
*/
const parseMarkdownWithCharts = (markdown) => {
if (!markdown) return [];
const parts = [];
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = echartsRegex.exec(markdown)) !== null) {
// 添加代码块前的文本
if (match.index > lastIndex) {
const textBefore = markdown.substring(lastIndex, match.index).trim();
if (textBefore) {
parts.push({ type: 'text', content: textBefore });
}
}
// 添加 ECharts 配置
const chartConfig = match[1].trim();
parts.push({ type: 'chart', content: chartConfig });
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < markdown.length) {
const textAfter = markdown.substring(lastIndex).trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
}
}
// 如果没有找到图表,返回整个 markdown 作为文本
if (parts.length === 0) {
parts.push({ type: 'text', content: markdown });
}
return parts;
};
/**
* 支持 ECharts 图表的 Markdown 渲染组件
* @param {string} content - Markdown 文本
*/
export const MarkdownWithCharts = ({ content }) => {
const parts = parseMarkdownWithCharts(content);
return (
<VStack align="stretch" spacing={4}>
{parts.map((part, index) => {
if (part.type === 'text') {
// 渲染普通 Markdown
return (
<Box key={index}>
<ReactMarkdown
components={{
// 自定义渲染样式
p: ({ children }) => (
<Text mb={2} fontSize="sm">
{children}
</Text>
),
h1: ({ children }) => (
<Text fontSize="xl" fontWeight="bold" mb={3}>
{children}
</Text>
),
h2: ({ children }) => (
<Text fontSize="lg" fontWeight="bold" mb={2}>
{children}
</Text>
),
h3: ({ children }) => (
<Text fontSize="md" fontWeight="bold" mb={2}>
{children}
</Text>
),
ul: ({ children }) => (
<Box as="ul" pl={4} mb={2}>
{children}
</Box>
),
ol: ({ children }) => (
<Box as="ol" pl={4} mb={2}>
{children}
</Box>
),
li: ({ children }) => (
<Box as="li" fontSize="sm" mb={1}>
{children}
</Box>
),
code: ({ inline, children }) =>
inline ? (
<Code fontSize="sm" px={1}>
{children}
</Code>
) : (
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
{children}
</Code>
),
blockquote: ({ children }) => (
<Box
borderLeftWidth="4px"
borderLeftColor="blue.500"
pl={4}
py={2}
fontStyle="italic"
color="gray.600"
>
{children}
</Box>
),
}}
>
{part.content}
</ReactMarkdown>
</Box>
);
} else if (part.type === 'chart') {
// 渲染 ECharts 图表
try {
// 清理可能的 Markdown 残留符号
let cleanContent = part.content.trim();
// 移除可能的前后空白和不可见字符
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
// 尝试解析 JSON
const chartOption = JSON.parse(cleanContent);
// 验证是否是有效的 ECharts 配置
if (!chartOption || typeof chartOption !== 'object') {
throw new Error('Invalid chart configuration: not an object');
}
return (
<Box key={index}>
<EChartsRenderer option={chartOption} height={350} />
</Box>
);
} catch (error) {
// 记录详细的错误信息
logger.error('解析 ECharts 配置失败', {
error: error.message,
contentLength: part.content.length,
contentPreview: part.content.substring(0, 200),
errorStack: error.stack
});
return (
<Alert status="warning" key={index} borderRadius="md">
<AlertIcon />
<VStack align="flex-start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="bold">
图表配置解析失败
</Text>
<Text fontSize="xs" color="gray.600">
错误: {error.message}
</Text>
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
{part.content.substring(0, 300)}
{part.content.length > 300 ? '...' : ''}
</Code>
</VStack>
</Alert>
);
}
}
return null;
})}
</VStack>
);
};

View File

@@ -0,0 +1,149 @@
// src/components/ChatBot/MessageBubble.js
// 聊天消息气泡组件
import React from 'react';
import {
Box,
Flex,
Text,
Avatar,
useColorModeValue,
IconButton,
HStack,
Code,
Badge,
VStack,
} from '@chakra-ui/react';
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
import ReactMarkdown from 'react-markdown';
/**
* 消息气泡组件
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {boolean} props.isUser - 是否是用户消息
* @param {Function} props.onCopy - 复制消息回调
* @param {Function} props.onFeedback - 反馈回调
*/
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
const userBg = useColorModeValue('blue.500', 'blue.600');
const botBg = useColorModeValue('gray.100', 'gray.700');
const userColor = 'white';
const botColor = useColorModeValue('gray.800', 'white');
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
onCopy?.();
};
return (
<Flex
w="100%"
justify={isUser ? 'flex-end' : 'flex-start'}
mb={4}
>
<Flex
maxW="75%"
flexDirection={isUser ? 'row-reverse' : 'row'}
align="flex-start"
>
{/* 头像 */}
<Avatar
size="sm"
name={isUser ? '用户' : 'AI助手'}
bg={isUser ? 'blue.500' : 'green.500'}
color="white"
mx={3}
/>
{/* 消息内容 */}
<Box>
<Box
bg={isUser ? userBg : botBg}
color={isUser ? userColor : botColor}
px={4}
py={3}
borderRadius="lg"
boxShadow="sm"
>
{message.type === 'text' ? (
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
) : message.type === 'markdown' ? (
<Box fontSize="sm" className="markdown-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</Box>
) : message.type === 'data' ? (
<VStack align="stretch" spacing={2}>
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
<Box
key={idx}
p={3}
bg={useColorModeValue('white', 'gray.600')}
borderRadius="md"
fontSize="xs"
>
{Object.entries(item).map(([key, value]) => (
<Flex key={key} justify="space-between" mb={1}>
<Text fontWeight="bold" mr={2}>{key}:</Text>
<Text>{String(value)}</Text>
</Flex>
))}
</Box>
))}
{message.data && message.data.length > 5 && (
<Badge colorScheme="blue" alignSelf="center">
+{message.data.length - 5} 更多结果
</Badge>
)}
</VStack>
) : null}
</Box>
{/* 消息操作按钮仅AI消息 */}
{!isUser && (
<HStack mt={2} spacing={2}>
<IconButton
icon={<FiCopy />}
size="xs"
variant="ghost"
aria-label="复制"
onClick={handleCopy}
/>
<IconButton
icon={<FiThumbsUp />}
size="xs"
variant="ghost"
aria-label="赞"
onClick={() => onFeedback?.('positive')}
/>
<IconButton
icon={<FiThumbsDown />}
size="xs"
variant="ghost"
aria-label="踩"
onClick={() => onFeedback?.('negative')}
/>
</HStack>
)}
{/* 时间戳 */}
<Text
fontSize="xs"
color="gray.500"
mt={1}
textAlign={isUser ? 'right' : 'left'}
>
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}) : ''}
</Text>
</Box>
</Flex>
</Flex>
);
};
export default MessageBubble;

View File

@@ -0,0 +1,145 @@
// src/components/ChatBot/PlanCard.js
// 执行计划展示卡片
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Icon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons/fi';
/**
* 执行计划卡片组件
*/
export const PlanCard = ({ plan, stepResults }) => {
const cardBg = useColorModeValue('blue.50', 'blue.900');
const borderColor = useColorModeValue('blue.200', 'blue.700');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const pendingColor = useColorModeValue('gray.400', 'gray.500');
const getStepStatus = (stepIndex) => {
if (!stepResults || stepResults.length === 0) return 'pending';
const result = stepResults.find(r => r.step_index === stepIndex);
return result ? result.status : 'pending';
};
const getStepIcon = (status) => {
switch (status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStepColor = (status) => {
switch (status) {
case 'success':
return successColor;
case 'failed':
return errorColor;
default:
return pendingColor;
}
};
return (
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="2px"
borderColor={borderColor}
p={4}
mb={4}
boxShadow="md"
>
<VStack align="stretch" spacing={3}>
{/* 目标 */}
<HStack>
<Icon as={FiTarget} color="blue.500" boxSize={5} />
<Text fontWeight="bold" fontSize="md">执行目标</Text>
</HStack>
<Text fontSize="sm" color="gray.600" pl={7}>
{plan.goal}
</Text>
<Divider />
{/* 规划思路 */}
{plan.reasoning && (
<>
<Text fontSize="sm" fontWeight="bold">规划思路</Text>
<Text fontSize="sm" color="gray.600">
{plan.reasoning}
</Text>
<Divider />
</>
)}
{/* 执行步骤 */}
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">执行步骤</Text>
<Badge colorScheme="blue">{plan.steps.length} </Badge>
</HStack>
<VStack align="stretch" spacing={2}>
{plan.steps.map((step, index) => {
const status = getStepStatus(index);
const StepIcon = getStepIcon(status);
const stepColor = getStepColor(status);
return (
<HStack
key={index}
p={2}
bg={useColorModeValue('white', 'gray.700')}
borderRadius="md"
borderWidth="1px"
borderColor={stepColor}
align="flex-start"
>
<Icon as={StepIcon} color={stepColor} boxSize={4} mt={1} />
<VStack align="stretch" flex={1} spacing={1}>
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">
步骤 {index + 1}: {step.tool}
</Text>
<Badge
colorScheme={
status === 'success' ? 'green' :
status === 'failed' ? 'red' : 'gray'
}
fontSize="xs"
>
{status === 'success' ? '✓ 完成' :
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{step.reason}
</Text>
</VStack>
</HStack>
);
})}
</VStack>
</VStack>
</Box>
);
};
export default PlanCard;

View File

@@ -0,0 +1,186 @@
// src/components/ChatBot/StepResultCard.js
// 步骤结果展示卡片(可折叠)
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Collapse,
Icon,
IconButton,
Code,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDatabase } from 'react-icons/fi';
/**
* 步骤结果卡片组件
*/
export const StepResultCard = ({ stepResult }) => {
const [isExpanded, setIsExpanded] = useState(false);
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const getStatusIcon = () => {
switch (stepResult.status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStatusColor = () => {
switch (stepResult.status) {
case 'success':
return 'green';
case 'failed':
return 'red';
default:
return 'gray';
}
};
const StatusIcon = getStatusIcon();
const statusColorScheme = getStatusColor();
// 格式化数据以便展示
const formatResult = (data) => {
if (typeof data === 'string') return data;
if (Array.isArray(data)) {
return `找到 ${data.length} 条记录`;
}
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
}
return String(data);
};
return (
<Box
bg={cardBg}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
boxShadow="sm"
>
{/* 头部 - 始终可见 */}
<HStack
p={3}
justify="space-between"
cursor="pointer"
onClick={() => setIsExpanded(!isExpanded)}
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
>
<HStack flex={1}>
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
<VStack align="stretch" spacing={0} flex={1}>
<HStack>
<Text fontSize="sm" fontWeight="bold">
步骤 {stepResult.step_index + 1}: {stepResult.tool}
</Text>
<Badge colorScheme={statusColorScheme} fontSize="xs">
{stepResult.status === 'success' ? '成功' :
stepResult.status === 'failed' ? '失败' : '执行中'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.500">
耗时: {stepResult.execution_time?.toFixed(2)}s
</Text>
</VStack>
</HStack>
<IconButton
icon={<Icon as={isExpanded ? FiChevronUp : FiChevronDown} />}
size="sm"
variant="ghost"
aria-label={isExpanded ? "收起" : "展开"}
/>
</HStack>
{/* 内容 - 可折叠 */}
<Collapse in={isExpanded} animateOpacity>
<Box p={3} pt={0}>
<Divider mb={3} />
{/* 参数 */}
{stepResult.arguments && Object.keys(stepResult.arguments).length > 0 && (
<VStack align="stretch" spacing={2} mb={3}>
<HStack>
<Icon as={FiDatabase} color="blue.500" boxSize={4} />
<Text fontSize="xs" fontWeight="bold">请求参数:</Text>
</HStack>
<Code
p={2}
borderRadius="md"
fontSize="xs"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{JSON.stringify(stepResult.arguments, null, 2)}
</Code>
</VStack>
)}
{/* 结果或错误 */}
{stepResult.status === 'success' && stepResult.result && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold">执行结果:</Text>
<Box
maxH="300px"
overflowY="auto"
p={2}
bg={useColorModeValue('gray.50', 'gray.800')}
borderRadius="md"
fontSize="xs"
>
{typeof stepResult.result === 'string' ? (
<Text whiteSpace="pre-wrap">{stepResult.result}</Text>
) : Array.isArray(stepResult.result) ? (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold">找到 {stepResult.result.length} 条记录:</Text>
{stepResult.result.slice(0, 3).map((item, idx) => (
<Code key={idx} p={2} borderRadius="md" fontSize="xs">
{JSON.stringify(item, null, 2)}
</Code>
))}
{stepResult.result.length > 3 && (
<Text fontSize="xs" color="gray.500">
...还有 {stepResult.result.length - 3} 条记录
</Text>
)}
</VStack>
) : (
<Code whiteSpace="pre-wrap" wordBreak="break-word">
{JSON.stringify(stepResult.result, null, 2)}
</Code>
)}
</Box>
</VStack>
)}
{stepResult.status === 'failed' && stepResult.error && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
{stepResult.error}
</Text>
</VStack>
)}
</Box>
</Collapse>
</Box>
);
};
export default StepResultCard;

View File

@@ -0,0 +1,11 @@
// src/components/ChatBot/index.js
// 聊天机器人组件统一导出
export { ChatInterface } from './ChatInterface';
export { ChatInterfaceV2 } from './ChatInterfaceV2';
export { MessageBubble } from './MessageBubble';
export { PlanCard } from './PlanCard';
export { StepResultCard } from './StepResultCard';
// 默认导出新版本
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';

View File

@@ -2,6 +2,7 @@
// 集中管理应用的全局组件
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useNotification } from '../contexts/NotificationContext';
import { logger } from '../utils/logger';
@@ -12,6 +13,10 @@ import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以在这里包装
@@ -67,8 +72,12 @@ function ConnectionStatusBarWrapper() {
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/
export function GlobalComponents() {
const location = useLocation();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<>
{/* Socket 连接状态条 */}
@@ -85,6 +94,14 @@ export function GlobalComponents() {
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
)}
</>
);
}

View File

@@ -1,3 +0,0 @@
.leaflet-container {
height: 300px;
}

View File

@@ -1,28 +0,0 @@
import { MapContainer, TileLayer, } from 'react-leaflet';
import "./Map.css";
function MapPlaceholder() {
return (
<p>
Map of London.{' '}
<noscript>You need to enable JavaScript to see this map.</noscript>
</p>
)
}
function Map() {
return (
<MapContainer
center={[51.505, -0.09]}
zoom={13}
scrollWheelZoom={true}
placeholder={<MapPlaceholder />}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</MapContainer>
)
}
export default Map;

View File

@@ -106,7 +106,29 @@ const FollowingEventsMenu = memo(() => {
)}
</HStack>
</Box>
<HStack flexShrink={0}>
<HStack flexShrink={0} spacing={1}>
{/* 热度 */}
{typeof ev.hot_score === 'number' && (
<Badge
colorScheme={
ev.hot_score >= 80 ? 'red' :
(ev.hot_score >= 60 ? 'orange' : 'gray')
}
fontSize="xs"
>
🔥 {ev.hot_score}
</Badge>
)}
{/* 关注数 */}
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
<Badge
colorScheme="purple"
fontSize="xs"
>
👥 {ev.follower_count}
</Badge>
)}
{/* 日均涨跌幅 */}
{typeof ev.related_avg_chg === 'number' && (
<Badge
colorScheme={
@@ -119,6 +141,7 @@ const FollowingEventsMenu = memo(() => {
{ev.related_avg_chg.toFixed(2)}%
</Badge>
)}
{/* 周涨跌幅 */}
{typeof ev.related_week_chg === 'number' && (
<Badge
colorScheme={
@@ -131,6 +154,7 @@ const FollowingEventsMenu = memo(() => {
{ev.related_week_chg.toFixed(2)}%
</Badge>
)}
{/* 取消关注按钮 */}
<Box
as="span"
fontSize="xs"

View File

@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
<Box>
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
<VStack spacing={2} align="stretch">
<Link
onClick={() => handleNavigate('/agent-chat')}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">AI</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link>
<Link
py={1}
px={3}

View File

@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</MenuButton>
<MenuList
minW="300px"
p={4}
p={2}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
navigate('/agent-chat');
agentCommunityMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"

View File

@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
{/* AGENT社群组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
<MenuItem
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/agent-chat');
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</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>
</MenuItem>

View File

@@ -1,277 +0,0 @@
import React from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Text,
Box,
VStack,
Divider,
useColorModeValue
} from "@chakra-ui/react";
const PrivacyPolicyModal = ({ isOpen, onClose }) => {
const modalBg = useColorModeValue("white", "gray.800");
const headingColor = useColorModeValue("gray.800", "white");
const textColor = useColorModeValue("gray.600", "gray.300");
// Conditional rendering: only render Modal when open
if (!isOpen) {
return null;
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
isCentered
>
<ModalOverlay bg="blackAlpha.600" />
<ModalContent
maxW="95vw"
maxH="95vh"
bg={modalBg}
borderRadius="xl"
boxShadow="2xl"
mx={4}
>
<ModalHeader
fontSize="2xl"
fontWeight="bold"
color={headingColor}
borderBottom="1px solid"
borderColor="gray.200"
borderRadius="xl xl 0 0"
py={6}
>
隐私政策
</ModalHeader>
<ModalCloseButton
size="lg"
_hover={{ bg: "gray.100" }}
/>
<ModalBody py={8} px={8}>
<VStack spacing={6} align="stretch">
<Box bg="blue.50" p={4} borderRadius="md" border="1px solid" borderColor="blue.100">
<Text fontSize="md" color="blue.600" mb={2} fontWeight="semibold">
生效日期2025年1月20日
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
北京价值前沿科技有限公司以下简称"我们"深知个人信息对您的重要性并会尽全力保护您的个人信息安全可靠我们致力于维持您对我们的信任恪守以下原则保护您的个人信息权责一致原则目的明确原则选择同意原则最少够用原则确保安全原则主体参与原则公开透明原则等同时我们承诺我们将按业界成熟的安全标准采取相应的安全保护措施来保护您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" fontWeight="medium">
请在使用我们的产品或服务仔细阅读并了解本隐私政策
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
我们如何收集和使用您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
根据信息安全技术个人信息安全规范GB/T 352732020个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息本隐私政策中涉及的个人信息包括基本信息包括性别地址地区个人电话号码电子邮箱个人身份信息包括身份证护照相关身份证明等网络身份标识信息包括系统账号IP地址口令个人上网记录包括登录记录浏览记录个人常用设备信息包括硬件型号操作系统类型应用安装列表运行中进程信息设备MAC地址软件列表设备识别码如IMEI/android ID/IDFA/IMSI 在内的描述个人常用设备基本情况的信息个人位置信息包括精准定位信息经纬度等
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
个人敏感信息是指一旦泄露非法提供或滥用可能危害人身和财产安全极易导致个人名誉身心健康受到损害或歧视性待遇等的个人信息本隐私政策中涉及的个人敏感信息包括个人身份信息包括身份证护照相关身份证明等网络身份识别信息包括账户名账户昵称用户头像与前述有关的密码其他信息包括个人电话号码浏览记录精准定位信息对于个人敏感信息我们将在本政策中进行显著标识请您仔细阅读
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md">
<Text fontSize="xl" fontWeight="semibold" color="green.700" mb={2}>
手机号注册/登录
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
当您使用手机号注册/登录服务时我们会收集您的手机号码验证码匹配结果手机系统平台等信息用于保存您的登录信息使您在使用不同设备登录时能够同步您的数据
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md">
<Text fontSize="xl" fontWeight="semibold" color="purple.700" mb={2}>
第三方登录
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
当您使用微信/QQ等第三方登录时我们会收集您第三方的唯一标识头像昵称用于保存您的登录信息使您在使用不同设备登录时能够同步您的数据
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
当您使用微信,微博QQ 进行三方分享的时候我们的产品可能会集成第三方的SDK或其他类似的应用程序用于三方登录以及分享内容到三方平台您可以登陆以下网址了解相关隐私政策
</Text>
<VStack align="start" spacing={2} pl={4}>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
新浪微博微博个人信息保护政策https://m.weibo.cn/c/privacy
</Text>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
微信微信开放平台开发者服务协议https://open.weixin.qq.com/cgi-bin/frame?t=news/protocol_developer_tmpl
</Text>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
QQQQ互联SDK隐私保护声明https://wiki.connect.qq.com/qq互联sdk隐私保护声明
</Text>
</VStack>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="orange.300" bg="orange.50" p={4} borderRadius="md">
<Text fontSize="xl" fontWeight="semibold" color="orange.700" mb={2}>
第三方支付
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
当您使用 微信 支付宝 华为 进行三方支付的时候我们的产品可能会集成第三方的SDK或其他类似的应用程序帮助用户在应用内使用三方支付
</Text>
<VStack align="start" spacing={2} pl={4}>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
支付宝客户端 SDK 隐私说明https://opendocs.alipay.com/open/01g6qm
</Text>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
微信支付微信支付服务协议https://pay.weixin.qq.com/index.php/public/apply_sign/protocol_v2
</Text>
<Text fontSize="md" color="blue.600" lineHeight="1.6">
华为支付SDK数据安全说明https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/sdk-data-security-0000001050044906
</Text>
</VStack>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
我们如何使用 Cookie 和同类技术
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
为确保网站正常运转我们会在您的计算机或移动设备上存储名为 Cookie 的小数据文件Cookie 通常包含标识符站点名称以及一些号码和字符借助于 Cookie网站能够存储您的偏好或购物篮内的商品等数据
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
我们如何共享转让公开披露您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
我们不会向其他任何公司组织和个人分享您的个人信息但以下情况除外
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
1在获取明确同意的情况下共享获得您的明确同意后我们会与其他方共享您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
2我们可能会根据法律法规规定或按政府主管部门的强制性要求对外共享您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
3与我们的关联公司共享您的个人信息可能会与我们关联公司共享我们只会共享必要的个人信息且受本隐私政策中所声明目的的约束关联公司如要改变个人信息的处理目的将再次征求您的授权同意
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
我们的关联公司包括北京价值经纬咨询有限责任公司等
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
我们如何保护您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
我们已使用符合业界标准的安全防护措施保护您提供的个人信息防止数据遭到未经授权访问公开披露使用修改损坏或丢失我们会采取一切合理可行的措施保护您的个人信息例如在您的浏览器与"服务"之间交换数据如信用卡信息时受 SSL 加密保护我们同时对我们网站提供 https 安全浏览方式我们会使用加密技术确保数据的保密性我们会使用受信赖的保护机制防止数据遭到恶意攻击我们会部署访问控制机制确保只有授权人员才可访问个人信息以及我们会举办安全和隐私保护培训课程加强员工对于保护个人信息重要性的认识
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
您的权利
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
按照中国相关的法律法规标准以及其他国家地区的通行做法我们保障您对自己的个人信息行使以下权利
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
访问您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
更正您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
删除您的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
约束信息系统自动决策
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
如果您无法通过上述链接更正这些个人信息您可以随时使用我们的 Web 表单联系或发送电子邮件至admin@valuefrontier.cn我们将在30天内回复您的更正请求
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
我们如何处理儿童的个人信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
我们的产品网站和服务主要面向成人如果没有父母或监护人的同意儿童不得创建自己的用户账户
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
对于经父母同意而收集儿童个人信息的情况我们只会在受到法律允许父母或监护人明确同意或者保护儿童所必要的情况下使用或公开披露此信息
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
尽管当地法律和习俗对儿童的定义不同但我们将不满 14 周岁的任何人均视为儿童
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
如果我们发现自己在未事先获得可证实的父母同意的情况下收集了儿童的个人信息则会设法尽快删除相关数据
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
本隐私政策如何更新
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
我们可能适时会对本隐私政策进行调整或变更本隐私政策的任何更新将在用户启动应用时以弹窗形式提醒用户更新内容并提示查看最新的隐私政策提醒用户重新确认是否同意隐私政策条款除法律法规或监管规定另有强制性规定外经调整或变更的内容一经用户确认后将即时生效
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
如何联系我们
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
如果您对本隐私政策有任何疑问意见或建议通过以下方式与我们联系
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
邮箱admin@valuefrontier.cn
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
未成年人保护方面
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
1若您是未满18周岁的未成年人您应在您的监护人监护指导下并获得监护人同意的情况下认真阅读并同意本协议后方可使用价值前沿app及相关服务若您未取得监护人的同意监护人可以通过联系价值前沿官方公布的客服联系方式通知价值前沿处理相关账号价值前沿有权对相关账号的功能使用进行限制包括但不限于浏览发布信息互动交流等功能
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
2价值前沿重视对未成年人个人信息的保护未成年用户在填写个人信息时请加强个人保护意识并谨慎对待并应在取得监护人的同意以及在监护人指导下正确使用价值前沿app及相关服务
</Text>
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
3未成年人用户及其监护人理解并确认如您违反法律法规本协议内容则您及您的监护人应依照法律规定承担因此而可能导致的全部法律责任
</Text>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default PrivacyPolicyModal;

View File

@@ -3,6 +3,8 @@
import React from 'react';
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
import { getChangeColor } from '../utils/colorUtils';
/**
* 股票涨跌幅指标组件3分天下布局
@@ -10,41 +12,26 @@ import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
* @param {number} props.avgChange - 平均涨跌幅
* @param {number} props.maxChange - 最大涨跌幅
* @param {number} props.weekChange - 周涨跌幅
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式default=紧凑comfortable=舒适事件列表large=大卡片(详情面板)
*/
const StockChangeIndicators = ({
avgChange,
maxChange,
weekChange,
size = 'default',
}) => {
// 根据涨跌幅获取数字颜色多颜色梯度5级分级
const isLarge = size === 'large';
const isComfortable = size === 'comfortable';
const isDefault = size === 'default';
// 根据涨跌幅获取数字颜色(动态深浅)
const getNumberColor = (value) => {
if (value == null) {
return useColorModeValue('gray.700', 'gray.400');
}
// 0值使用中性灰色
if (value === 0) {
return 'gray.700';
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨:红色系 → 橙色系
if (absValue >= 10) return 'red.900'; // 10%以上:最深红
if (absValue >= 5) return 'red.700'; // 5-10%:深红
if (absValue >= 3) return 'red.500'; // 3-5%:中红
if (absValue >= 1) return 'orange.600'; // 1-3%:橙色
return 'orange.400'; // 0-1%:浅橙
} else {
// 下跌:绿色系 → 青色系
if (absValue >= 10) return 'green.900'; // -10%以下:最深绿
if (absValue >= 5) return 'green.700'; // -10% ~ -5%:深绿
if (absValue >= 3) return 'green.500'; // -5% ~ -3%:中绿
if (absValue >= 1) return 'teal.600'; // -3% ~ -1%:青色
return 'teal.400'; // -1% ~ 0%:浅青
}
// 使用动态颜色函数
return getChangeColor(value);
};
// 根据涨跌幅获取背景色(永远比文字色浅)
@@ -58,24 +45,10 @@ const StockChangeIndicators = ({
return useColorModeValue('gray.50', 'gray.800');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨背景:红色系 → 橙色系(统一使用 50 最浅色)
if (absValue >= 10) return useColorModeValue('red.50', 'red.900');
if (absValue >= 5) return useColorModeValue('red.50', 'red.900');
if (absValue >= 3) return useColorModeValue('red.50', 'red.900');
if (absValue >= 1) return useColorModeValue('orange.50', 'orange.900');
return useColorModeValue('orange.50', 'orange.900');
} else {
// 下跌背景:绿色系 → 青色系(统一使用 50 最浅色)
if (absValue >= 10) return useColorModeValue('green.50', 'green.900');
if (absValue >= 5) return useColorModeValue('green.50', 'green.900');
if (absValue >= 3) return useColorModeValue('green.50', 'green.900');
if (absValue >= 1) return useColorModeValue('teal.50', 'teal.900');
return useColorModeValue('teal.50', 'teal.900');
}
// 统一背景色:上涨红色系,下跌绿色系
return value > 0
? useColorModeValue('red.50', 'red.900')
: useColorModeValue('green.50', 'green.900');
};
// 根据涨跌幅获取边框色(比背景深,比文字浅)
@@ -89,64 +62,86 @@ const StockChangeIndicators = ({
return useColorModeValue('gray.200', 'gray.700');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨边框:红色系 → 橙色系(跟随文字深浅)
if (absValue >= 10) return useColorModeValue('red.200', 'red.800'); // 文字 red.900
if (absValue >= 5) return useColorModeValue('red.200', 'red.700'); // 文字 red.700
if (absValue >= 3) return useColorModeValue('red.100', 'red.600'); // 文字 red.500
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700'); // 文字 orange.600
return useColorModeValue('orange.100', 'orange.600'); // 文字 orange.400
} else {
// 下跌边框:绿色系 → 青色系(跟随文字深浅)
if (absValue >= 10) return useColorModeValue('green.200', 'green.800'); // 文字 green.900
if (absValue >= 5) return useColorModeValue('green.200', 'green.700'); // 文字 green.700
if (absValue >= 3) return useColorModeValue('green.100', 'green.600'); // 文字 green.500
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700'); // 文字 teal.600
return useColorModeValue('teal.100', 'teal.600'); // 文字 teal.400
}
// 统一边框色:上涨红色系,下跌绿色系
return value > 0
? useColorModeValue('red.200', 'red.700')
: useColorModeValue('green.200', 'green.700');
};
// 渲染单个指标
const renderIndicator = (label, value) => {
if (value == null) return null;
const sign = value > 0 ? '+' : '';
// 0值显示为 "0",其他值显示位小数
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(1);
const sign = value > 0 ? '+' : '-';
// 0值显示为 "0",其他值显示位小数
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(2);
const numberColor = getNumberColor(value);
const bgColor = getBgColor(value);
const borderColor = getBorderColor(value);
const labelColor = useColorModeValue('gray.700', 'gray.400');
const labelColor = useColorModeValue('gray.600', 'gray.400');
return (
<Box
bg={bgColor}
borderWidth="2px"
borderWidth={isLarge ? "2px" : "1px"}
borderColor={borderColor}
borderRadius="md"
px={1.5}
py={0.5}
px={isLarge ? 4 : (isDefault ? 1.5 : (isComfortable ? 3 : 2))}
py={isLarge ? 3 : (isDefault ? 1.5 : (isComfortable ? 2 : 1))}
display="flex"
alignItems="center"
justifyContent="center"
flexDirection={(isLarge || isDefault) ? "column" : "row"}
alignItems={(isLarge || isDefault) ? "flex-start" : "center"}
gap={(isLarge || isDefault) ? (isLarge ? 2 : 1) : 1}
maxW={isLarge ? "200px" : "none"}
flex="0 1 auto"
minW="0"
>
<Text fontSize="xs" lineHeight="1.2">
<Text as="span" color={labelColor}>
{label}
{/* Large 和 Default 模式:标签单独一行 */}
{(isLarge || isDefault) && (
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
{label.trim()}
</Text>
<Text as="span" color={labelColor}>
{sign}
)}
{/* 数值 + 图标 */}
<Flex align="center" gap={isLarge ? 2 : (isDefault ? 1 : 1)}>
{/* 三角形图标 */}
{value !== 0 && (
value > 0 ? (
<TriangleUpIcon
w={2}
h={2}
color={numberColor}
/>
) : (
<TriangleDownIcon
w={2}
h={2}
color={numberColor}
/>
)
)}
{/* 数字 */}
<Text
fontSize={isLarge ? "2xl" : (isDefault ? "md" : "lg")}
fontWeight="bold"
color={numberColor}
lineHeight="1.2"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{/* Comfortable 模式:标签和数字在同一行 */}
{!isLarge && !isDefault && (
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
{label}
</Text>
)}
{sign}{numStr}
<Text as="span" fontWeight="medium" fontSize="sm">%</Text>
</Text>
<Text as="span" fontWeight="bold" color={numberColor} fontSize="sm">
{value < 0 ? '-' : ''}{numStr}
</Text>
<Text as="span" color={labelColor}>
%
</Text>
</Text>
</Flex>
</Box>
);
};
@@ -157,10 +152,10 @@ const StockChangeIndicators = ({
}
return (
<Flex width="100%" justify="space-between" align="center" gap={1}>
{renderIndicator('平均 ', avgChange)}
{renderIndicator('最大 ', maxChange)}
{renderIndicator('周涨 ', weekChange)}
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
{renderIndicator('平均涨幅', avgChange)}
{renderIndicator('最大涨幅', maxChange)}
{renderIndicator('周涨', weekChange)}
</Flex>
);
};

View File

@@ -29,6 +29,9 @@ import {
Td,
Heading,
Collapse,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import React, { useState, useEffect } from 'react';
import { logger } from '../../utils/logger';
@@ -76,7 +79,8 @@ export default function SubscriptionContent() {
// State
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const [selectedPlan, setSelectedPlan] = useState(null);
const [selectedCycle, setSelectedCycle] = useState('monthly');
const [selectedCycle, setSelectedCycle] = useState('monthly'); // 保持向后兼容,默认月付
const [selectedCycleOption, setSelectedCycleOption] = useState(null); // 当前选中的pricing_option对象
const [paymentOrder, setPaymentOrder] = useState(null);
const [loading, setLoading] = useState(false);
const [paymentCountdown, setPaymentCountdown] = useState(0);
@@ -85,6 +89,13 @@ export default function SubscriptionContent() {
const [forceUpdating, setForceUpdating] = useState(false);
const [openFaqIndex, setOpenFaqIndex] = useState(null);
// 优惠码相关state
const [promoCode, setPromoCode] = useState('');
const [promoCodeApplied, setPromoCodeApplied] = useState(false);
const [promoCodeError, setPromoCodeError] = useState('');
const [validatingPromo, setValidatingPromo] = useState(false);
const [priceInfo, setPriceInfo] = useState(null); // 价格信息(包含升级计算)
// 加载订阅套餐数据
useEffect(() => {
fetchSubscriptionPlans();
@@ -149,7 +160,102 @@ export default function SubscriptionContent() {
}
};
const handleSubscribe = (plan) => {
// 计算价格(包含升级和优惠码)
const calculatePrice = async (plan, cycle, promoCodeValue = null) => {
try {
// 确保优惠码值正确只接受非空字符串其他情况传null
const validPromoCode = promoCodeValue && typeof promoCodeValue === 'string' && promoCodeValue.trim()
? promoCodeValue.trim()
: null;
logger.debug('SubscriptionContent', '计算价格', {
plan: plan.name,
cycle,
promoCodeValue,
validPromoCode
});
const response = await fetch('/api/subscription/calculate-price', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
to_plan: plan.name,
to_cycle: cycle,
promo_code: validPromoCode
})
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setPriceInfo(data.data);
return data.data;
}
}
return null;
} catch (error) {
logger.error('SubscriptionContent', 'calculatePrice', error);
return null;
}
};
// 验证优惠码
const handleValidatePromoCode = async () => {
const trimmedCode = promoCode.trim();
if (!trimmedCode) {
setPromoCodeError('请输入优惠码');
return;
}
if (!selectedPlan) {
setPromoCodeError('请先选择套餐');
return;
}
setValidatingPromo(true);
setPromoCodeError('');
try {
// 重新计算价格,包含优惠码(使用去除空格后的值)
const result = await calculatePrice(selectedPlan, selectedCycle, trimmedCode);
if (result && !result.promo_error) {
setPromoCodeApplied(true);
toast({
title: '优惠码已应用',
description: `节省 ¥${result.discount_amount.toFixed(2)}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
setPromoCodeError(result?.promo_error || '优惠码无效');
setPromoCodeApplied(false);
}
} catch (error) {
setPromoCodeError('验证失败,请重试');
setPromoCodeApplied(false);
} finally {
setValidatingPromo(false);
}
};
// 移除优惠码
const handleRemovePromoCode = async () => {
setPromoCode('');
setPromoCodeApplied(false);
setPromoCodeError('');
// 重新计算价格(不含优惠码)
if (selectedPlan) {
await calculatePrice(selectedPlan, selectedCycle, null);
}
};
const handleSubscribe = async (plan) => {
if (!user) {
toast({
title: '请先登录',
@@ -178,6 +284,10 @@ export default function SubscriptionContent() {
);
setSelectedPlan(plan);
// 计算价格(包含升级判断)
await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null);
onPaymentModalOpen();
};
@@ -186,7 +296,7 @@ export default function SubscriptionContent() {
setLoading(true);
try {
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
const price = priceInfo?.final_amount || (selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price);
// 🎯 追踪支付发起
subscriptionEvents.trackPaymentInitiated({
@@ -205,7 +315,8 @@ export default function SubscriptionContent() {
credentials: 'include',
body: JSON.stringify({
plan_name: selectedPlan.name,
billing_cycle: selectedCycle
billing_cycle: selectedCycle,
promo_code: promoCodeApplied ? promoCode : null
})
});
@@ -477,15 +588,83 @@ export default function SubscriptionContent() {
const getCurrentPrice = (plan) => {
if (!plan) return 0;
// 如果有pricing_options使用它
if (plan.pricing_options && plan.pricing_options.length > 0) {
// 查找当前选中的周期选项
const option = plan.pricing_options.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
return option ? option.price : plan.monthly_price;
}
// 向后兼容回退到monthly_price/yearly_price
return selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
};
const getSavingsText = (plan) => {
if (!plan || selectedCycle !== 'yearly') return null;
const yearlyTotal = plan.monthly_price * 12;
const savings = yearlyTotal - plan.yearly_price;
const percentage = Math.round((savings / yearlyTotal) * 100);
return `年付节省 ${percentage}%`;
if (!plan) return null;
// 如果有pricing_options从中查找discount_percent
if (plan.pricing_options && plan.pricing_options.length > 0) {
const currentOption = plan.pricing_options.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
if (currentOption && currentOption.discount_percent) {
return `立减 ${currentOption.discount_percent}%`;
}
// 如果没有discount_percent尝试计算节省金额
if (currentOption && currentOption.months > 1) {
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
if (monthlyOption) {
const expectedTotal = monthlyOption.price * currentOption.months;
const savings = expectedTotal - currentOption.price;
if (savings > 0) {
const percentage = Math.round((savings / expectedTotal) * 100);
return `${currentOption.label || `${currentOption.months}个月`}节省 ${percentage}%`;
}
}
}
}
// 向后兼容:计算年付节省
if (selectedCycle === 'yearly') {
const yearlyTotal = plan.monthly_price * 12;
const savings = yearlyTotal - plan.yearly_price;
if (savings > 0) {
const percentage = Math.round((savings / yearlyTotal) * 100);
return `年付节省 ${percentage}%`;
}
}
return null;
};
// 获取按钮文字(根据用户当前订阅判断是升级还是新订阅)
const getButtonText = (plan, user) => {
if (!user || user.subscription_type === 'free') {
return `选择 ${plan.display_name}`;
}
// 判断是否为升级
const planLevels = { 'free': 0, 'pro': 1, 'max': 2 };
const currentLevel = planLevels[user.subscription_type] || 0;
const targetLevel = planLevels[plan.name] || 0;
if (targetLevel > currentLevel) {
return `升级至 ${plan.display_name}`;
} else if (targetLevel < currentLevel) {
return `切换至 ${plan.display_name}`;
} else {
// 同级别,可能是切换周期
return `切换至 ${plan.display_name}`;
}
};
// 统一的功能列表定义 - 基于商业定价(10月15日)文档
@@ -578,36 +757,107 @@ export default function SubscriptionContent() {
)}
{/* 计费周期选择 */}
<Flex justify="center">
<HStack
spacing={0}
bg={bgAccent}
borderRadius="lg"
p={1}
border="1px solid"
borderColor={borderColor}
>
<Button
variant={selectedCycle === 'monthly' ? 'solid' : 'ghost'}
colorScheme={selectedCycle === 'monthly' ? 'blue' : 'gray'}
size="md"
onClick={() => setSelectedCycle('monthly')}
borderRadius="md"
<Box>
<Text textAlign="center" fontSize="sm" color={secondaryText} mb={3}>
选择计费周期 · 时长越长优惠越大
</Text>
<Flex justify="center" mb={2}>
<HStack
spacing={2}
bg={bgAccent}
borderRadius="xl"
p={2}
border="1px solid"
borderColor={borderColor}
flexWrap="wrap"
justify="center"
>
按月付费
</Button>
<Button
variant={selectedCycle === 'yearly' ? 'solid' : 'ghost'}
colorScheme={selectedCycle === 'yearly' ? 'blue' : 'gray'}
size="md"
onClick={() => setSelectedCycle('yearly')}
borderRadius="md"
>
按年付费
<Badge ml={2} colorScheme="red" fontSize="xs">省20%</Badge>
</Button>
</HStack>
</Flex>
{(() => {
// 获取第一个套餐的pricing_options作为周期选项假设所有套餐都有相同的周期
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
const cycleOptions = firstPlan?.pricing_options || [
{ cycle_key: 'monthly', label: '月付', months: 1 },
{ cycle_key: 'yearly', label: '年付', months: 12, discount_percent: 20 }
];
return cycleOptions.map((option, index) => {
const cycleKey = option.cycle_key || (option.months === 1 ? 'monthly' : option.months === 12 ? 'yearly' : `${option.months}months`);
const isSelected = selectedCycle === cycleKey;
const hasDiscount = option.discount_percent && option.discount_percent > 0;
return (
<VStack
key={index}
spacing={0}
position="relative"
>
{/* 折扣标签 */}
{hasDiscount && (
<Badge
position="absolute"
top="-8px"
colorScheme="red"
fontSize="xs"
px={2}
borderRadius="full"
fontWeight="bold"
>
{option.discount_percent}%
</Badge>
)}
<Button
variant={isSelected ? 'solid' : 'outline'}
colorScheme={isSelected ? 'blue' : 'gray'}
size="md"
onClick={() => setSelectedCycle(cycleKey)}
borderRadius="lg"
minW="80px"
h="50px"
position="relative"
_hover={{
transform: 'translateY(-2px)',
shadow: 'md'
}}
transition="all 0.2s"
>
<VStack spacing={0}>
<Text fontSize="md" fontWeight="bold">
{option.label || `${option.months}个月`}
</Text>
{hasDiscount && (
<Text fontSize="xs" color={isSelected ? 'white' : 'gray.500'}>
更优惠
</Text>
)}
</VStack>
</Button>
</VStack>
);
});
})()}
</HStack>
</Flex>
{/* 提示文字 */}
{(() => {
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
const cycleOptions = firstPlan?.pricing_options || [];
const currentOption = cycleOptions.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
if (currentOption && currentOption.discount_percent > 0) {
return (
<Text textAlign="center" fontSize="sm" color="green.600" fontWeight="medium">
🎉 当前选择可节省 {currentOption.discount_percent}% 的费用
</Text>
);
}
return null;
})()}
</Box>
{/* 订阅套餐 */}
<Grid
@@ -780,19 +1030,69 @@ export default function SubscriptionContent() {
{getCurrentPrice(plan).toFixed(0)}
</Text>
<Text fontSize="sm" color={secondaryText}>
/{selectedCycle === 'monthly' ? '' : ''}
{(() => {
if (plan.pricing_options) {
const option = plan.pricing_options.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
if (option) {
// 如果months是1显示"/月"如果是12显示"/年"否则显示周期label
if (option.months === 1) return '/月';
if (option.months === 12) return '/年';
return `/${option.months}个月`;
}
}
return selectedCycle === 'monthly' ? '/月' : '/年';
})()}
</Text>
</HStack>
</Flex>
<Flex justify="space-between" align="center">
<Text fontSize="xs" color={secondaryText} pl={11}>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
{plan.description}
</Text>
{getSavingsText(plan) && (
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
{getSavingsText(plan)}
</Badge>
)}
{(() => {
// 获取当前选中的周期信息
if (plan.pricing_options) {
const currentOption = plan.pricing_options.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
if (currentOption && currentOption.discount_percent > 0) {
// 计算原价和节省金额
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
if (monthlyOption) {
const originalPrice = monthlyOption.price * currentOption.months;
const savedAmount = originalPrice - currentOption.price;
return (
<VStack spacing={0} align="flex-end">
<Badge colorScheme="red" fontSize="xs" px={3} py={1} borderRadius="full">
立省 {currentOption.discount_percent}%
</Badge>
<Text fontSize="xs" color="gray.500" textDecoration="line-through">
原价¥{originalPrice.toFixed(0)}
</Text>
<Text fontSize="xs" color="green.600" fontWeight="bold">
¥{savedAmount.toFixed(0)}
</Text>
</VStack>
);
}
return (
<Badge colorScheme="green" fontSize="xs" px={3} py={1} borderRadius="full">
{getSavingsText(plan)}
</Badge>
);
}
}
return null;
})()}
</Flex>
</VStack>
@@ -838,7 +1138,8 @@ export default function SubscriptionContent() {
onClick={() => handleSubscribe(plan)}
isDisabled={
user?.subscription_type === plan.name &&
user?.subscription_status === 'active'
user?.subscription_status === 'active' &&
user?.billing_cycle === selectedCycle
}
_hover={{
transform: 'scale(1.02)',
@@ -846,9 +1147,10 @@ export default function SubscriptionContent() {
transition="all 0.2s"
>
{user?.subscription_type === plan.name &&
user?.subscription_status === 'active'
? '✓ 已订阅'
: `选择 ${plan.display_name}`
user?.subscription_status === 'active' &&
user?.billing_cycle === selectedCycle
? '✓ 当前套餐'
: getButtonText(plan, user)
}
</Button>
</VStack>
@@ -958,7 +1260,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
可以在月付和年付之间切换吗
升级或切换套餐时原套餐的费用怎么办
</Text>
<Icon
as={openFaqIndex === 2 ? FaChevronUp : FaChevronDown}
@@ -967,14 +1269,28 @@ export default function SubscriptionContent() {
</Flex>
<Collapse in={openFaqIndex === 2}>
<Box p={4} pt={0} color={secondaryText}>
<Text>
可以您可以随时更改计费周期如果从月付切换到年付系统会计算剩余价值并应用到新的订阅中年付用户可享受20%的折扣优惠
</Text>
<VStack spacing={2} align="stretch">
<Text>
当您升级套餐或切换计费周期时系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用
</Text>
<Text fontWeight="medium" fontSize="sm">
计算方式
</Text>
<Text fontSize="sm" pl={3}>
<strong>剩余价值</strong> = 原套餐价格 × (剩余天数 / 总天数)
</Text>
<Text fontSize="sm" pl={3}>
<strong>实付金额</strong> = - -
</Text>
<Text fontSize="sm" color="blue.600" mt={2}>
例如您购买了年付Pro版¥999使用了180天后升级到Max版¥1999/剩余价值约¥500将自动抵扣实付约¥1499
</Text>
</VStack>
</Box>
</Collapse>
</Box>
{/* FAQ 4 */}
{/* FAQ 4 - 原FAQ 3 */}
<Box
border="1px solid"
borderColor={borderColor}
@@ -992,7 +1308,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
是否提供退款
可以在月付和年付之间切换吗
</Text>
<Icon
as={openFaqIndex === 3 ? FaChevronUp : FaChevronDown}
@@ -1002,13 +1318,13 @@ export default function SubscriptionContent() {
<Collapse in={openFaqIndex === 3}>
<Box p={4} pt={0} color={secondaryText}>
<Text>
我们提供7天无理由退款保证如果您在订阅后7天内对服务不满意可以申请全额退款超过7天后我们将根据实际使用情况进行评估
可以您可以随时更改计费周期如果从月付切换到年付系统会计算剩余价值并应用到新的订阅中年付用户可享受20%的折扣优惠
</Text>
</Box>
</Collapse>
</Box>
{/* FAQ 5 */}
{/* FAQ 5 - 原FAQ 4 */}
<Box
border="1px solid"
borderColor={borderColor}
@@ -1026,7 +1342,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
Pro版和Max版有什么区别
是否支持退款
</Text>
<Icon
as={openFaqIndex === 4 ? FaChevronUp : FaChevronDown}
@@ -1034,6 +1350,60 @@ export default function SubscriptionContent() {
/>
</Flex>
<Collapse in={openFaqIndex === 4}>
<Box p={4} pt={0} color={secondaryText}>
<VStack spacing={2} align="stretch">
<Text>
为了保障服务质量和维护公平的商业环境我们<strong>不支持退款</strong>
</Text>
<Text fontSize="sm">
建议您在订阅前
</Text>
<Text fontSize="sm" pl={3}>
充分了解各套餐的功能差异
</Text>
<Text fontSize="sm" pl={3}>
使用免费版体验基础功能
</Text>
<Text fontSize="sm" pl={3}>
根据实际需求选择合适的计费周期
</Text>
<Text fontSize="sm" pl={3}>
如有疑问可联系客服咨询
</Text>
<Text fontSize="sm" color="blue.600" mt={2}>
提示选择长期套餐如半年付年付可享受更大折扣性价比更高
</Text>
</VStack>
</Box>
</Collapse>
</Box>
{/* FAQ 6 - 原FAQ 5 */}
<Box
border="1px solid"
borderColor={borderColor}
borderRadius="lg"
overflow="hidden"
>
<Flex
p={4}
cursor="pointer"
onClick={() => setOpenFaqIndex(openFaqIndex === 5 ? null : 5)}
bg={openFaqIndex === 5 ? bgAccent : 'transparent'}
_hover={{ bg: bgAccent }}
transition="all 0.2s"
justify="space-between"
align="center"
>
<Text fontWeight="semibold" color={textColor}>
Pro版和Max版有什么区别
</Text>
<Icon
as={openFaqIndex === 5 ? FaChevronUp : FaChevronDown}
color={textColor}
/>
</Flex>
<Collapse in={openFaqIndex === 5}>
<Box p={4} pt={0} color={secondaryText}>
<Text>
Pro版适合个人专业用户提供高级图表历史数据分析等功能Max版则是为团队和企业设计额外提供实时数据推送API访问无限制的数据存储和团队协作功能并享有优先技术支持
@@ -1052,6 +1422,11 @@ export default function SubscriptionContent() {
stopAutoPaymentCheck();
setPaymentOrder(null);
setPaymentCountdown(0);
// 清空优惠码状态
setPromoCode('');
setPromoCodeApplied(false);
setPromoCodeError('');
setPriceInfo(null);
onPaymentModalClose();
}}
size="lg"
@@ -1082,16 +1457,76 @@ export default function SubscriptionContent() {
</Flex>
<Flex justify="space-between">
<Text color={secondaryText}>计费周期:</Text>
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
</Flex>
<Divider />
<Flex justify="space-between" align="baseline">
<Text color={secondaryText}>应付金额:</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
¥{getCurrentPrice(selectedPlan).toFixed(2)}
<Text>
{(() => {
if (selectedPlan?.pricing_options) {
const option = selectedPlan.pricing_options.find(opt =>
opt.cycle_key === selectedCycle ||
(selectedCycle === 'monthly' && opt.months === 1) ||
(selectedCycle === 'yearly' && opt.months === 12)
);
return option?.label || (selectedCycle === 'monthly' ? '按月付费' : '按年付费');
}
return selectedCycle === 'monthly' ? '按月付费' : '按年付费';
})()}
</Text>
</Flex>
{getSavingsText(selectedPlan) && (
{/* 价格明细 */}
<Divider my={2} />
{priceInfo && priceInfo.is_upgrade && (
<Box bg="blue.50" p={3} borderRadius="md" mb={2}>
<HStack spacing={2} mb={2}>
<Icon as={FaCheck} color="blue.500" boxSize={4} />
<Text fontSize="sm" fontWeight="bold" color="blue.700">
{priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' :
priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'}
</Text>
</HStack>
<VStack spacing={1} align="stretch" fontSize="xs">
<Flex justify="space-between" color="gray.600">
<Text>当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'})</Text>
</Flex>
<Flex justify="space-between" color="gray.600">
<Text>剩余价值:</Text>
<Text>¥{priceInfo.remaining_value.toFixed(2)}</Text>
</Flex>
</VStack>
</Box>
)}
<Flex justify="space-between">
<Text color={secondaryText}>
{priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'}
</Text>
<Text fontWeight="medium">
¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
</Text>
</Flex>
{priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
<Flex justify="space-between" color="blue.600">
<Text>已付剩余抵扣:</Text>
<Text>-¥{priceInfo.remaining_value.toFixed(2)}</Text>
</Flex>
)}
{priceInfo && priceInfo.discount_amount > 0 && (
<Flex justify="space-between" color="green.600">
<Text>优惠码折扣:</Text>
<Text>-¥{priceInfo.discount_amount.toFixed(2)}</Text>
</Flex>
)}
<Divider />
<Flex justify="space-between" align="baseline">
<Text fontSize="lg" fontWeight="bold">实付金额:</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
</Text>
</Flex>
{getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && (
<Badge colorScheme="green" alignSelf="flex-end" fontSize="xs">
{getSavingsText(selectedPlan)}
</Badge>
@@ -1104,6 +1539,53 @@ export default function SubscriptionContent() {
</Box>
)}
{/* 优惠码输入 */}
{selectedPlan && (
<Box>
<HStack spacing={2}>
<Input
placeholder="输入优惠码(可选)"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value.toUpperCase());
setPromoCodeError('');
}}
size="md"
isDisabled={promoCodeApplied}
/>
<Button
colorScheme="purple"
onClick={handleValidatePromoCode}
isLoading={validatingPromo}
isDisabled={!promoCode || promoCodeApplied}
minW="80px"
>
应用
</Button>
</HStack>
{promoCodeError && (
<Text color="red.500" fontSize="sm" mt={2}>
{promoCodeError}
</Text>
)}
{promoCodeApplied && priceInfo && (
<HStack mt={2} p={2} bg="green.50" borderRadius="md">
<Icon as={FaCheck} color="green.500" />
<Text color="green.700" fontSize="sm" fontWeight="medium" flex={1}>
优惠码已应用节省 ¥{priceInfo.discount_amount.toFixed(2)}
</Text>
<Icon
as={FaTimes}
color="gray.500"
cursor="pointer"
onClick={handleRemovePromoCode}
_hover={{ color: 'red.500' }}
/>
</HStack>
)}
</Box>
)}
<Button
colorScheme="green"
size="lg"

View File

@@ -0,0 +1,83 @@
// src/components/SubscriptionBadge.js
// 会员专享标签组件
import React from 'react';
import {
Badge,
HStack,
Icon,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { FaStar, FaCrown } from 'react-icons/fa';
import { logger } from '../utils/logger';
/**
* 会员专享标签组件
* @param {Object} props
* @param {'pro' | 'max'} props.tier - 会员等级pro 或 max
* @param {'sm' | 'md'} props.size - 标签尺寸
*/
const SubscriptionBadge = ({ tier = 'pro', size = 'sm' }) => {
// PRO 和 MAX 配置
const config = {
pro: {
label: 'PRO专享',
icon: FaStar,
bgGradient: 'linear(to-r, blue.400, purple.500)',
color: 'white',
},
max: {
label: 'MAX专享',
icon: FaCrown,
bgGradient: 'linear(to-r, pink.400, red.500)',
color: 'white',
},
};
const tierConfig = config[tier] || config.pro;
// 尺寸配置
const sizeConfig = {
sm: {
fontSize: 'xs',
iconSize: 2.5,
px: 2,
py: 0.5,
},
md: {
fontSize: 'sm',
iconSize: 3,
px: 3,
py: 1,
},
};
const currentSize = sizeConfig[size] || sizeConfig.sm;
return (
<Badge
bgGradient={tierConfig.bgGradient}
color={tierConfig.color}
borderRadius="full"
px={currentSize.px}
py={currentSize.py}
fontSize={currentSize.fontSize}
fontWeight="bold"
boxShadow="sm"
display="inline-flex"
alignItems="center"
gap={1}
transition="all 0.2s"
_hover={{
transform: 'scale(1.05)',
boxShadow: 'md',
}}
>
<Icon as={tierConfig.icon} boxSize={currentSize.iconSize} />
<Text>{tierConfig.label}</Text>
</Badge>
);
};
export default SubscriptionBadge;

View File

@@ -1,275 +0,0 @@
import React from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Text,
Box,
VStack,
Divider,
useColorModeValue
} from "@chakra-ui/react";
const UserAgreementModal = ({ isOpen, onClose }) => {
const modalBg = useColorModeValue("white", "gray.800");
const headingColor = useColorModeValue("gray.800", "white");
const textColor = useColorModeValue("gray.600", "gray.300");
// Conditional rendering: only render Modal when open
if (!isOpen) {
return null;
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
isCentered
>
<ModalOverlay bg="blackAlpha.600" />
<ModalContent
maxW="95vw"
maxH="95vh"
bg={modalBg}
borderRadius="xl"
boxShadow="2xl"
mx={4}
>
<ModalHeader
fontSize="2xl"
fontWeight="bold"
color={headingColor}
borderBottom="1px solid"
borderColor="gray.200"
borderRadius="xl xl 0 0"
py={6}
>
价值前沿用户协议
</ModalHeader>
<ModalCloseButton
size="lg"
_hover={{ bg: "gray.100" }}
/>
<ModalBody py={8} px={8}>
<VStack spacing={6} align="stretch">
<Box bg="orange.50" p={6} borderRadius="lg" border="1px solid" borderColor="orange.100">
<Text fontSize="xl" fontWeight="bold" color="orange.700" mb={6}>
欢迎你使用价值前沿及服务
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
为使用价值前沿以下简称"本软件"及服务你应当阅读并遵守价值前沿用户协议以下简称"本协议"请你务必审慎阅读充分理解各条款内容特别是免除或者限制责任的条款以及开通或使用某项服务的单独协议并选择接受或不接受限制免责条款可能以加粗形式提示你注意
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
除非你已阅读并接受本协议所有条款否则你无权下载安装或使用本软件及相关服务你的下载安装使用获取价值前沿帐号登录等行为即视为你已阅读并同意上述协议的约束
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" fontWeight="medium">
如果你未满18周岁请在法定监护人的陪同下阅读本协议及其他上述协议并特别注意未成年人使用条款
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={6}>
协议的范围
</Text>
<Box pl={4} borderLeft="3px solid" borderColor="blue.300" bg="blue.50" p={4} borderRadius="md" mb={4}>
<Text fontSize="lg" fontWeight="semibold" color="blue.700" mb={2}>
1.1 协议适用主体范围
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8">
本协议是你与北京价值前沿科技有限公司之间关于你下载安装使用复制本软件以及使用价值前沿相关服务所订立的协议
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md" mb={4}>
<Text fontSize="lg" fontWeight="semibold" color="green.700" mb={2}>
1.2 协议关系及冲突条款
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8">
本协议内容同时包括北京价值前沿科技有限公司可能不断发布的关于本服务的相关协议业务规则等内容上述内容一经正式发布即为本协议不可分割的组成部分你同样应当遵守
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md" mb={4}>
<Text fontSize="lg" fontWeight="semibold" color="purple.700" mb={2}>
1.3 许可范围
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8">
明确标识免费产品的用户可以进行自用的非商业性无限制数量地下载安装及使用但不得复制分发其他收费类产品或者信息除遵守本协议规定之外还须遵守专门协议的规定
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="red.300" bg="red.50" p={4} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
1.4 权利限制
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8">
禁止反向工程反向编译和反向汇编用户不得对价值前沿软件类产品进行反向工程反向编译或反向汇编同时不得改动编译程序文件内部的任何资源除法律法规明文规定允许上述活动外用户必须遵守此协议限制
</Text>
</Box>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
关于本服务
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
本服务内容是指北京价值前沿科技有限公司向用户提供的跨平台的社交资讯工具以下简称"价值前沿"支持单人多人参与在发布图片和文字等内容服务的基础上同时为用户提供包括但不限于社交关系拓展便捷工具等功能或内容的软件服务以下简称"本服务"
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
免责条款
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
北京价值前沿科技有限公司作为面向全球投资人提供信息和服务的商家对以下情况不承担相关责任
</Text>
<VStack align="stretch" spacing={3}>
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
1不可抗力
</Text>
<Text fontSize="md" color={textColor} lineHeight="1.6">
如因发生自然灾害战争第三方侵害等不可控因素而发生的信息服务中断价值前沿不承担相应责任
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
2信息网络传播
</Text>
<Text fontSize="md" color={textColor} lineHeight="1.6">
如因信息网络传播中的拥塞断续病毒木马黑客窃取侦听等网络通道上的因素而造成信息缺失丢失延迟被篡改等价值前沿不对此承担相应责任
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
3第三方信息的收集整理
</Text>
<Text fontSize="md" color={textColor} lineHeight="1.6">
价值前沿为了更好地服务投资者便于用户分析研判投资环境尽可能多地收集整理来自第三方的所有信息分门别类地提供给用户参考并明确标识为来自第三方的信息而对内容的真实性合理性完整性合法性等并不承担判断责任也不承担用户因信息而造成的损失责任
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
4证券信息汇总
</Text>
<Text fontSize="md" color={textColor} lineHeight="1.6">
价值前沿仅提供证券信息汇总及证券投资品种历史数据统计功能不针对用户提供任何情况判断投资参考品种操作建议等等不属于荐股软件用户按照自身对于市场环境的分析研判而做出的评论参考用户可以结合自身需求予以借鉴并自行作出判断风险和收益都由用户自行承担
</Text>
</Box>
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
5信息储存
</Text>
<Text fontSize="md" color={textColor} lineHeight="1.6">
用户在使用价值前沿系统时会因信息注册产品购买软件使用过程中的某些需求而留存于系统中的账户密码真实身份联系方式用户网络信息等个人信息价值前沿将按照国家相关规定进行必要的保护
</Text>
</Box>
</VStack>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
用户个人信息保护
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
保护用户个人信息是北京价值前沿科技有限公司的一项基本原则北京价值前沿科技有限公司将会采取合理的措施保护用户的个人信息除法律法规规定的情形外未经用户许可北京价值前沿科技有限公司不会向第三方公开透露用户个人信息
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
你在注册帐号或使用本服务的过程中需要提供一些必要的信息例如为向你提供帐号注册服务或进行用户身份识别需要你填写手机号码手机通讯录匹配功能需要你授权访问手机通讯录等若国家法律法规或政策有特殊规定的你需要提供真实的身份信息
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
用户行为规范
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
你理解并同意价值前沿一直致力于为用户提供文明健康规范有序的网络环境你不得利用价值前沿帐号或本软件及服务制作复制发布传播干扰正常运营以及侵犯其他用户或第三方合法权益的内容
</Text>
<Box pl={4} borderLeft="3px solid" borderColor="red.400" bg="red.50" p={4} borderRadius="md">
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
禁止内容包括但不限于
</Text>
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="md" color={textColor}> 违反宪法确定的基本原则的内容</Text>
<Text fontSize="md" color={textColor}> 危害国家安全泄露国家秘密的内容</Text>
<Text fontSize="md" color={textColor}> 损害国家荣誉和利益的内容</Text>
<Text fontSize="md" color={textColor}> 散布谣言扰乱社会秩序的内容</Text>
<Text fontSize="md" color={textColor}> 散布淫秽色情赌博暴力恐怖的内容</Text>
<Text fontSize="md" color={textColor}> 侮辱或者诽谤他人侵害他人合法权益的内容</Text>
<Text fontSize="md" color={textColor}> 其他违反法律法规的内容</Text>
</VStack>
</Box>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
知识产权声明
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
北京价值前沿科技有限公司是本软件的知识产权权利人本软件的一切著作权商标权专利权商业秘密等知识产权以及与本软件相关的所有信息内容包括但不限于文字图片音频视频图表界面设计版面框架有关数据或电子文档等均受中华人民共和国法律法规和相应的国际条约保护
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
未经北京价值前沿科技有限公司或相关权利人书面同意你不得为任何商业或非商业目的自行或许可任何第三方实施利用转让上述知识产权
</Text>
</Box>
<Divider />
<Box>
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
其他条款
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
你使用本软件即视为你已阅读并同意受本协议的约束北京价值前沿科技有限公司有权在必要时修改本协议条款你可以在本软件的最新版本中查阅相关协议条款
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
本协议签订地为中华人民共和国北京市海淀区本协议的成立生效履行解释及纠纷解决适用中华人民共和国大陆地区法律不包括冲突法
</Text>
<Text fontSize="lg" color={textColor} lineHeight="1.8">
若你和北京价值前沿科技有限公司之间发生任何纠纷或争议首先应友好协商解决协商不成的你同意将纠纷或争议提交本协议签订地有管辖权的人民法院管辖
</Text>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default UserAgreementModal;

View File

@@ -19,9 +19,12 @@ export const IMPORTANCE_LEVELS = {
bgColor: 'red.50',
borderColor: 'red.200',
colorScheme: 'red',
badgeBg: 'red.800', // 角标边框和文字颜色 - 极深红色
badgeBg: '#dc2626', // 圆形徽章背景色 - 红色
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: WarningIcon,
label: '极高',
stampText: '极', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.800',
description: '重大事件,市场影响深远',
antdColor: '#cf1322',
@@ -32,9 +35,12 @@ export const IMPORTANCE_LEVELS = {
bgColor: 'red.50',
borderColor: 'red.200',
colorScheme: 'red',
badgeBg: 'red.600', // 角标边框和文字颜色 - 深红
badgeBg: '#ea580c', // 圆形徽章背景色 -
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: WarningTwoIcon,
label: '高',
stampText: '高', // 印章文字
stampFont: "'STXingkai', 'FangSong', 'STFangsong', cursive", // 行楷/草书
dotBg: 'red.600',
description: '重要事件,影响较大',
antdColor: '#ff4d4f',
@@ -45,9 +51,12 @@ export const IMPORTANCE_LEVELS = {
bgColor: 'red.50',
borderColor: 'red.100',
colorScheme: 'red',
badgeBg: 'red.500', // 角标边框和文字颜色 - 中红
badgeBg: '#2563eb', // 圆形徽章背景色 -
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: InfoIcon,
label: '中',
stampText: '中', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.500',
description: '普通事件,有一定影响',
antdColor: '#ff7875',
@@ -58,9 +67,12 @@ export const IMPORTANCE_LEVELS = {
bgColor: 'red.50',
borderColor: 'red.100',
colorScheme: 'red',
badgeBg: 'red.400', // 角标边框和文字颜色 - 浅红色
badgeBg: '#10b981', // 圆形徽章背景色 - 青绿色(替代灰色)
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: CheckCircleIcon,
label: '低',
stampText: '低', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.400',
description: '参考事件,影响有限',
antdColor: '#ffa39e',

View File

@@ -292,8 +292,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
console.log('[NotificationContext] 通知数据:', notificationData);
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
return;
}
@@ -305,6 +310,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
title,
body: content,
tag,
requireInteraction,
link
});
// 发送浏览器通知
const notification = browserNotificationService.sendNotification({
title: title || '新通知',
@@ -315,17 +328,24 @@ export const NotificationProvider = ({ children }) => {
autoClose: requireInteraction ? 0 : 8000,
});
// 设置点击处理(聚焦窗口并跳转)
if (notification && link) {
notification.onclick = () => {
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
// 设置点击处理(聚焦窗口并跳转)
if (link) {
notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link);
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else {
console.error('[NotificationContext] ❌ 通知对象创建失败!');
}
}, [browserPermission]);
/**
@@ -610,6 +630,24 @@ export const NotificationProvider = ({ children }) => {
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
socket.startMockPush(interval, maxBatch);
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
} else {
// ✅ 真实模式下,订阅事件推送
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
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);
},
// ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听
});
} else {
console.warn('[NotificationContext] ⚠️ socket.subscribeToEvents 方法不可用');
}
}
});
@@ -646,6 +684,15 @@ export const NotificationProvider = ({ children }) => {
// 监听新事件推送(统一事件名)
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);
// ========== Socket层去重检查 ==========
@@ -653,11 +700,14 @@ 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;');
return; // 重复事件,直接忽略
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
// 限制Set大小避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
@@ -670,8 +720,14 @@ export const NotificationProvider = ({ children }) => {
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
console.log('[NotificationContext] 正在转换事件格式...');
const notification = adaptEventToNotification(data);
console.log('[NotificationContext] 转换后的通知对象:', notification);
console.log('[NotificationContext] 准备添加通知到队列...');
addNotification(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
// 保留系统通知监听(兼容性)

View File

@@ -2,8 +2,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
// 本地引入 Leaflet 样式,替代不稳定的 CDN 外链
import 'leaflet/dist/leaflet.css';
// 导入 Tailwind CSS 和 Brainwave 样式
import './styles/brainwave.css';
import './styles/brainwave-colors.css';
@@ -11,6 +9,53 @@ import './styles/brainwave-colors.css';
// Import the main App component
import App from './App';
// 导入通知服务并挂载到 window用于调试
import { browserNotificationService } from './services/browserNotificationService';
window.browserNotificationService = browserNotificationService;
// 注册 Service Worker用于支持浏览器通知
function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
console.log(
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
'color: #FF9800; font-weight: bold;'
);
return;
}
// 仅在支持 Service Worker 的浏览器中注册
if ('serviceWorker' in navigator) {
// 在页面加载完成后注册
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
console.log('[App] Service Worker registered successfully:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('[App] Service Worker update found');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('[App] Service Worker activated');
}
});
}
});
})
.catch((error) => {
console.error('[App] Service Worker registration failed:', error);
});
});
} else {
console.warn('[App] Service Worker is not supported in this browser');
}
}
// 启动 Mock Service Worker如果启用
async function startApp() {
// 只在开发环境启动 MSW
@@ -35,6 +80,9 @@ async function startApp() {
</Router>
</React.StrictMode>
);
// 注册 Service Worker
registerServiceWorker();
}
// 启动应用

View File

@@ -8,14 +8,14 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
*/
const AppFooter = () => {
return (
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
<Container maxW="container.xl">
<VStack spacing={2}>
<VStack spacing={1}>
<RiskDisclaimer />
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<HStack spacing={1} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal

View File

@@ -92,7 +92,7 @@ export const initPostHog = () => {
loaded: (posthogInstance) => {
if (process.env.NODE_ENV === 'development') {
console.log('✅ PostHog initialized successfully');
posthogInstance.debug(); // Enable debug mode in development
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
}
},
});
@@ -143,7 +143,7 @@ export const identifyUser = (userId, userProperties = {}) => {
...userProperties,
});
console.log('👤 User identified:', userId);
// console.log('👤 User identified:', userId); // 已关闭:减少日志
} catch (error) {
console.error('❌ User identification failed:', error);
}
@@ -158,7 +158,7 @@ export const identifyUser = (userId, userProperties = {}) => {
export const setUserProperties = (properties) => {
try {
posthog.people.set(properties);
console.log('📝 User properties updated');
// console.log('📝 User properties updated'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Failed to update user properties:', error);
}
@@ -177,9 +177,9 @@ export const trackEvent = (eventName, properties = {}) => {
timestamp: new Date().toISOString(),
});
if (process.env.NODE_ENV === 'development') {
console.log('📍 Event tracked:', eventName, properties);
}
// if (process.env.NODE_ENV === 'development') {
// console.log('📍 Event tracked:', eventName, properties);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Event tracking failed:', error);
}
@@ -201,9 +201,9 @@ export const trackPageView = (pagePath, properties = {}) => {
...properties,
});
if (process.env.NODE_ENV === 'development') {
console.log('📄 Page view tracked:', pagePath);
}
// if (process.env.NODE_ENV === 'development') {
// console.log('📄 Page view tracked:', pagePath);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Page view tracking failed:', error);
}
@@ -216,7 +216,7 @@ export const trackPageView = (pagePath, properties = {}) => {
export const resetUser = () => {
try {
posthog.reset();
console.log('🔄 User session reset');
// console.log('🔄 User session reset'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Session reset failed:', error);
}
@@ -228,7 +228,7 @@ export const resetUser = () => {
export const optOut = () => {
try {
posthog.opt_out_capturing();
console.log('🚫 User opted out of tracking');
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-out failed:', error);
}
@@ -240,7 +240,7 @@ export const optOut = () => {
export const optIn = () => {
try {
posthog.opt_in_capturing();
console.log('✅ User opted in to tracking');
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-in failed:', error);
}

View File

@@ -31,11 +31,11 @@ export async function startMockServiceWorker() {
try {
await worker.start({
// 🎯 智能穿透模式(关键配置)
// 🎯 警告模式(关键配置)
// 'bypass': 未定义 Mock 的请求自动转发到真实后端
// 'warn': 未定义的请求会显示警告(调试用)
// 'error': 未定义的请求会抛出错误(严格模式)
onUnhandledRequest: 'bypass',
// 'warn': 未定义的请求会显示警告(调试用)✅ 当前使用(允许 passthrough
// 'error': 未定义的请求会抛出错误(严格模式,不允许 passthrough
onUnhandledRequest: 'warn',
// 自定义 Service Worker URL如果需要
serviceWorker: {
@@ -48,12 +48,12 @@ export async function startMockServiceWorker() {
isStarted = true;
console.log(
'%c[MSW] Mock Service Worker 已启动 🎭',
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(
'%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'),
'color: #FF9800; font-size: 12px;'
'%c警告模式:已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
'color: #FF9800; font-weight: bold; font-size: 12px;'
);
console.log(
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',

View File

@@ -138,6 +138,9 @@ export const mockFollowingEvents = [
upvote_count: 489,
heat_score: 95,
exceed_expectation_score: 85,
related_avg_chg: 1.25,
related_max_chg: 3.15,
related_week_chg: 2.80,
creator: {
id: 1001,
username: '财经分析师',
@@ -155,6 +158,9 @@ export const mockFollowingEvents = [
upvote_count: 567,
heat_score: 88,
exceed_expectation_score: 78,
related_avg_chg: 5.60,
related_max_chg: 12.50,
related_week_chg: 8.90,
creator: {
id: 1002,
username: '科技观察者',
@@ -172,6 +178,9 @@ export const mockFollowingEvents = [
upvote_count: 345,
heat_score: 72,
exceed_expectation_score: 68,
related_avg_chg: 2.35,
related_max_chg: 6.80,
related_week_chg: 4.20,
creator: {
id: 1003,
username: '产业研究员',
@@ -189,6 +198,9 @@ export const mockFollowingEvents = [
upvote_count: 432,
heat_score: 80,
exceed_expectation_score: 72,
related_avg_chg: 3.80,
related_max_chg: 9.20,
related_week_chg: 6.50,
creator: {
id: 1004,
username: '半导体观察',
@@ -206,6 +218,9 @@ export const mockFollowingEvents = [
upvote_count: 234,
heat_score: 65,
exceed_expectation_score: null,
related_avg_chg: -0.80,
related_max_chg: 2.50,
related_week_chg: 1.20,
creator: {
id: 1005,
username: '医药行业专家',

View File

@@ -556,7 +556,7 @@ const industries = ['半导体', '新能源', '人工智能', '医药', '消费'
// 事件标题模板
const eventTitleTemplates = [
'{industry}行业迎来重大政策利好',
'{industry}行业迎来重大政策利好中国物流与采购联合会发布《国有企业采购业务监督指南》团体标准',
'{company}发布{quarter}财报,业绩超预期',
'{industry}板块集体大涨,{company}涨停',
'央行宣布{policy},影响{industry}行业',
@@ -928,13 +928,16 @@ export function generateMockEvents(params = {}) {
const end = start + per_page;
const paginatedEvents = filteredEvents.slice(start, end);
const totalPages = Math.ceil(filteredEvents.length / per_page);
return {
events: paginatedEvents,
pagination: {
page: page,
per_page: per_page,
total: filteredEvents.length,
total_pages: Math.ceil(filteredEvents.length / per_page),
pages: totalPages, // ← 对齐后端字段名 (was: total_pages)
has_prev: page > 1, // ← 对齐后端:是否有上一页
has_next: page < totalPages // ← 对齐后端:是否有下一页
},
};
}

View File

@@ -112,20 +112,20 @@ export function getCurrentUser() {
const stored = localStorage.getItem('mock_current_user');
if (stored) {
const user = JSON.parse(stored);
console.log('[Mock State] 获取当前登录用户:', {
id: user.id,
phone: user.phone,
nickname: user.nickname,
subscription_type: user.subscription_type,
subscription_status: user.subscription_status,
subscription_days_left: user.subscription_days_left
});
// console.log('[Mock State] 获取当前登录用户:', { // 已关闭:减少日志
// id: user.id,
// phone: user.phone,
// nickname: user.nickname,
// subscription_type: user.subscription_type,
// subscription_status: user.subscription_status,
// subscription_days_left: user.subscription_days_left
// });
return user;
}
} catch (error) {
console.error('[Mock State] 解析用户数据失败:', error);
}
console.log('[Mock State] 未找到当前登录用户');
// console.log('[Mock State] 未找到当前登录用户'); // 已关闭:减少日志
return null;
}

View File

@@ -130,7 +130,7 @@ export const accountHandlers = [
);
}
console.log('[Mock] 获取自选股列表');
// console.log('[Mock] 获取自选股列表'); // 已关闭:减少日志
return HttpResponse.json({
success: true,

View File

@@ -24,8 +24,6 @@ export const authHandlers = [
const body = await request.json();
const { credential, type, purpose } = body;
console.log('[Mock] 发送验证码:', { credential, type, purpose });
// 生成验证码
const code = generateVerificationCode();
mockVerificationCodes.set(credential, {
@@ -33,7 +31,20 @@ export const authHandlers = [
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
});
console.log(`[Mock] 验证码已生成: ${credential} -> ${code}`);
// 超醒目的验证码提示 - 方便开发调试
console.log(
`%c\n` +
`╔════════════════════════════════════════════╗\n` +
`║ 验证码: ${code.padEnd(22)}\n` +
`╚════════════════════════════════════════════╝\n`,
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
);
// 额外的高亮提示
console.log(
`%c 验证码: ${code} `,
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
);
return HttpResponse.json({
success: true,
@@ -43,6 +54,86 @@ export const authHandlers = [
});
}),
// 1.1 发送手机验证码(前端实际调用的接口)
http.post('/api/auth/send-sms-code', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
const { phone } = body;
console.log('[Mock] 发送手机验证码请求:', { phone });
// 生成验证码
const code = generateVerificationCode();
mockVerificationCodes.set(phone, {
code,
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
});
// 超醒目的验证码提示 - 方便开发调试
console.log(
`%c\n` +
`╔════════════════════════════════════════════╗\n` +
`║ 📱 手机验证码: ${code.padEnd(19)}\n` +
`║ 📞 手机号: ${phone.padEnd(23)}\n` +
`╚════════════════════════════════════════════╝\n`,
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
);
// 额外的高亮提示
console.log(
`%c 📱 验证码: ${code} `,
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
);
return HttpResponse.json({
success: true,
message: `验证码已发送到 ${phone}Mock: ${code}`,
// 开发环境下返回验证码,方便测试
dev_code: code
});
}),
// 1.2 发送邮箱验证码(前端实际调用的接口)
http.post('/api/auth/send-email-code', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
const { email } = body;
console.log('[Mock] 发送邮箱验证码请求:', { email });
// 生成验证码
const code = generateVerificationCode();
mockVerificationCodes.set(email, {
code,
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
});
// 超醒目的验证码提示 - 方便开发调试
console.log(
`%c\n` +
`╔════════════════════════════════════════════╗\n` +
`║ 📧 邮箱验证码: ${code.padEnd(19)}\n` +
`║ 📮 邮箱: ${email.padEnd(27)}\n` +
`╚════════════════════════════════════════════╝\n`,
'color: #ffffff; background: #2563eb; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
);
// 额外的高亮提示
console.log(
`%c 📧 验证码: ${code} `,
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
);
return HttpResponse.json({
success: true,
message: `验证码已发送到 ${email}Mock: ${code}`,
// 开发环境下返回验证码,方便测试
dev_code: code
});
}),
// 2. 验证码登录
http.post('/api/auth/login-with-code', async ({ request }) => {
await delay(NETWORK_DELAY);

View File

@@ -13,7 +13,7 @@ export const eventHandlers = [
// ==================== 事件列表相关 ====================
// 获取事件列表
http.get('/api/events/', async ({ request }) => {
http.get('/api/events', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
@@ -41,11 +41,26 @@ export const eventHandlers = [
});
} catch (error) {
console.error('[Mock] 获取事件列表失败:', error);
console.error('[Mock] Error details:', {
message: error.message,
stack: error.stack,
params: params
});
return HttpResponse.json(
{
success: false,
error: '获取事件列表失败',
data: { events: [], pagination: {} }
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
}
},
{ status: 500 }
);
@@ -1065,14 +1080,65 @@ export const eventHandlers = [
date.setDate(date.getDate() - daysAgo);
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
const title = eventTitles[i % eventTitles.length];
// 带引用来源的研报数据
const researchReports = [
{
author: '中信证券',
report_title: `${title}深度研究报告`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '国泰君安',
report_title: `行业专题:${title}影响分析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '华泰证券',
report_title: `${title}投资机会深度解析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString()
}
];
// 生成带引用标记的contentdata结构
const contentWithCitations = {
data: [
{
query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】市场关注度高相关概念股表现活跃。`,
sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛涉及多个细分领域投资机会显著。`,
match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'),
author: researchReports[0].author,
declare_date: researchReports[0].declare_date,
report_title: researchReports[0].report_title
},
{
query_part: `市场分析师认为该事件将带动产业链上下游企业协同发展【2】形成良性循环。`,
sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`,
match_score: importance >= 3 ? '好' : '中',
author: researchReports[1].author,
declare_date: researchReports[1].declare_date,
report_title: researchReports[1].report_title
},
{
query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`,
sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`,
match_score: '好',
author: researchReports[2].author,
declare_date: researchReports[2].declare_date,
report_title: researchReports[2].report_title
}
]
};
events.push({
id: `hist_event_${i + 1}`,
title: eventTitles[i % eventTitles.length],
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
title: title,
content: contentWithCitations, // 升级版本带引用来源的data结构
description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容
date: date.toISOString().split('T')[0],
importance: importance,
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
similarity: Math.floor(Math.random() * 10) + 1, // 1-10
impact_sectors: [
['半导体', '芯片设计', 'EDA'],
['新能源汽车', '锂电池', '充电桩'],

View File

@@ -0,0 +1,25 @@
// src/mocks/handlers/external.js
// 外部服务 Mock Handler允许通过
import { http, passthrough } from 'msw';
/**
* 外部服务处理器
* 对于外部服务如头像、CDN等使用 passthrough 让请求正常发送到真实服务器
*/
export const externalHandlers = [
// Pravatar 头像服务 - 允许通过到真实服务
http.get('https://i.pravatar.cc/*', async () => {
return passthrough();
}),
// 如果需要 mock 头像,也可以返回一个占位图片
// http.get('https://i.pravatar.cc/*', async () => {
// return HttpResponse.text(
// '<svg width="150" height="150" xmlns="http://www.w3.org/2000/svg"><rect width="150" height="150" fill="#ddd"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#999">Avatar</text></svg>',
// {
// headers: { 'Content-Type': 'image/svg+xml' }
// }
// );
// }),
];

View File

@@ -13,6 +13,8 @@ import { companyHandlers } from './company';
import { marketHandlers } from './market';
import { financialHandlers } from './financial';
import { limitAnalyseHandlers } from './limitAnalyse';
import { posthogHandlers } from './posthog';
import { externalHandlers } from './external';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -30,5 +32,7 @@ export const handlers = [
...marketHandlers,
...financialHandlers,
...limitAnalyseHandlers,
...posthogHandlers,
...externalHandlers,
// ...userHandlers,
];

View File

@@ -0,0 +1,132 @@
// src/mocks/handlers/posthog.js
// PostHog 埋点请求 Mock Handler
import { http, HttpResponse } from 'msw';
/**
* PostHog 埋点 Mock Handler
* 拦截所有发往 PostHog 的埋点请求,避免在 Mock 模式下产生 500 错误
*/
export const posthogHandlers = [
// PostHog 事件追踪接口
http.post('https://us.i.posthog.com/e/', async ({ request }) => {
try {
// 读取埋点数据(可选,用于调试)
const body = await request.text();
// 开发环境输出埋点日志(可选,方便调试)
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
console.log('[Mock] PostHog 埋点请求:', {
url: request.url,
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
});
}
// 返回成功响应(模拟 PostHog 服务器响应)
return HttpResponse.json(
{ status: 1 },
{ status: 200 }
);
} catch (error) {
console.error('[Mock] PostHog handler error:', error);
return HttpResponse.json(
{ status: 0, error: 'Mock handler error' },
{ status: 500 }
);
}
}),
// PostHog batch 批量事件追踪接口(可选)
http.post('https://us.i.posthog.com/batch/', async ({ request }) => {
try {
const body = await request.text();
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
console.log('[Mock] PostHog 批量埋点请求:', {
url: request.url,
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
});
}
return HttpResponse.json(
{ status: 1 },
{ status: 200 }
);
} catch (error) {
console.error('[Mock] PostHog batch handler error:', error);
return HttpResponse.json(
{ status: 0, error: 'Mock handler error' },
{ status: 500 }
);
}
}),
// PostHog decide 接口(功能开关、特性标志)
http.post('https://us.i.posthog.com/decide/', async ({ request }) => {
try {
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
const body = await request.json();
console.log('[Mock] PostHog decide 请求:', body);
}
// 返回空的特性标志配置
return HttpResponse.json({
featureFlags: {},
sessionRecording: false,
});
} catch (error) {
console.error('[Mock] PostHog decide handler error:', error);
return HttpResponse.json(
{ featureFlags: {}, sessionRecording: false },
{ status: 200 }
);
}
}),
// PostHog session recording 接口(会话录制)
http.post('https://us.i.posthog.com/s/', async ({ request }) => {
try {
const body = await request.text();
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
console.log('[Mock] PostHog session recording 请求:', {
url: request.url,
bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : ''),
});
}
// 返回成功响应
return HttpResponse.json(
{ status: 1 },
{ status: 200 }
);
} catch (error) {
console.error('[Mock] PostHog session recording handler error:', error);
return HttpResponse.json(
{ status: 0, error: 'Mock handler error' },
{ status: 500 }
);
}
}),
// PostHog feature flags 接口(特性标志查询)
http.post('https://us.i.posthog.com/flags/', async ({ request }) => {
try {
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
console.log('[Mock] PostHog feature flags 请求:', request.url);
}
// 返回空的特性标志
return HttpResponse.json({
featureFlags: {},
featureFlagPayloads: {},
});
} catch (error) {
console.error('[Mock] PostHog flags handler error:', error);
return HttpResponse.json(
{ featureFlags: {}, featureFlagPayloads: {} },
{ status: 200 }
);
}
}),
];

View File

@@ -130,7 +130,7 @@ export const stockHandlers = [
try {
const stocks = generateStockList();
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
// console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length }); // 已关闭:减少日志
return HttpResponse.json(stocks);
} catch (error) {

View File

@@ -35,6 +35,9 @@ export const lazyComponents = {
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
};
/**
@@ -59,4 +62,5 @@ export const {
ForecastReport,
FinancialPanorama,
MarketDataView,
AgentChat,
} = lazyComponents;

View File

@@ -149,6 +149,18 @@ export const routeConfig = [
description: '实时市场数据'
}
},
// ==================== Agent模块 ====================
{
path: 'agent-chat',
component: lazyComponents.AgentChat,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '价小前投研',
description: '北京价值前沿科技公司的AI投研聊天助手'
}
},
];
/**

View File

@@ -30,6 +30,14 @@ class BrowserNotificationService {
return Notification.permission;
}
/**
* 检查是否有通知权限
* @returns {boolean}
*/
hasPermission() {
return this.isSupported() && Notification.permission === 'granted';
}
/**
* 请求通知权限
* @returns {Promise<string>} 权限状态
@@ -77,57 +85,99 @@ class BrowserNotificationService {
data = {},
autoClose = 0,
}) {
// 详细日志:检查支持状态
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
console.warn('[browserNotificationService] ❌ 浏览器不支持通知 API');
return null;
}
if (this.permission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted');
// 详细日志:检查权限状态
const currentPermission = Notification.permission;
console.log('[browserNotificationService] 当前权限状态:', currentPermission);
if (currentPermission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted', { permission: currentPermission });
console.warn(`[browserNotificationService] ❌ 权限未授予: ${currentPermission}`);
return null;
}
console.log('[browserNotificationService] ✅ 准备发送通知:', { title, body, tag, requireInteraction, autoClose });
try {
// 关闭相同 tag 的旧通知
if (tag && this.activeNotifications.has(tag)) {
const oldNotification = this.activeNotifications.get(tag);
oldNotification.close();
console.log('[browserNotificationService] 关闭旧通知:', tag);
}
// 创建通知
const finalTag = tag || `notification_${Date.now()}`;
console.log('[browserNotificationService] 创建通知对象...');
const notification = new Notification(title, {
body,
icon,
badge: '/badge.png',
tag: tag || `notification_${Date.now()}`,
tag: finalTag,
requireInteraction,
data,
silent: false, // 允许声音
});
console.log('[browserNotificationService] ✅ 通知对象已创建:', notification);
// 存储通知引用
if (tag) {
this.activeNotifications.set(tag, notification);
console.log('[browserNotificationService] 通知已存储到活跃列表');
}
// 自动关闭
if (autoClose > 0 && !requireInteraction) {
console.log(`[browserNotificationService] 设置自动关闭: ${autoClose}ms`);
setTimeout(() => {
notification.close();
console.log('[browserNotificationService] 通知已自动关闭');
}, autoClose);
}
// 通知关闭时清理引用
notification.onclose = () => {
console.log('[browserNotificationService] 通知被关闭:', finalTag);
if (tag) {
this.activeNotifications.delete(tag);
}
};
logger.info('browserNotificationService', 'Notification sent', { title, tag });
// 通知点击事件
notification.onclick = (event) => {
console.log('[browserNotificationService] 通知被点击:', finalTag, data);
};
// 通知显示事件
notification.onshow = () => {
console.log('[browserNotificationService] ✅ 通知已显示:', finalTag);
};
// 通知错误事件
notification.onerror = (error) => {
console.error('[browserNotificationService] ❌ 通知显示错误:', error);
};
logger.info('browserNotificationService', 'Notification sent', { title, tag: finalTag });
console.log('[browserNotificationService] ✅ 通知发送成功!');
return notification;
} catch (error) {
logger.error('browserNotificationService', 'sendNotification', error);
console.error('[browserNotificationService] ❌ 发送通知时发生错误:', error);
console.error('[browserNotificationService] 错误详情:', {
name: error.name,
message: error.message,
stack: error.stack
});
return null;
}
}

View File

@@ -37,10 +37,12 @@ const apiRequest = async (url, options = {}) => {
export const eventService = {
getEvents: (params = {}) => {
// Filter out empty params
const cleanParams = Object.fromEntries(Object.entries(params).filter(([_, v]) => v != null && v !== ''));
// Filter out null, undefined, and empty strings (but keep 0 and false)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v !== null && v !== undefined && v !== '')
);
const query = new URLSearchParams(cleanParams).toString();
return apiRequest(`/api/events/?${query}`);
return apiRequest(`/api/events?${query}`);
},
getHotEvents: (params = {}) => {
const query = new URLSearchParams(params).toString();

278
src/services/llmService.js Normal file
View File

@@ -0,0 +1,278 @@
// src/services/llmService.js
// LLM服务层 - 集成AI模型进行对话和工具调用
import axios from 'axios';
import { mcpService } from './mcpService';
import { logger } from '../utils/logger';
/**
* LLM服务配置
*/
const LLM_CONFIG = {
// 可以使用 OpenAI、Claude、通义千问等
provider: 'openai', // 或 'claude', 'qwen'
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini', // 更便宜的模型
};
/**
* LLM服务类
*/
class LLMService {
constructor() {
this.conversationHistory = [];
}
/**
* 构建系统提示词
*/
getSystemPrompt(availableTools) {
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
${availableTools.map(tool => `
**${tool.name}**
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`).join('\n')}
用户提问时,请按照以下步骤:
1. 理解用户的意图
2. 选择合适的工具(可以多个)
3. 提取工具需要的参数
4. 调用工具后,用自然语言总结结果
回复格式:
- 如果需要调用工具返回JSON格式{"tool": "工具名", "arguments": {...}}
- 如果不需要工具,直接回复自然语言
注意:
- 贵州茅台的股票代码是 600519
- 涨停是指股票当日涨幅达到10%
- 概念板块是指相同题材的股票分类`;
}
/**
* 智能对话 - 使用LLM理解意图并调用工具
*/
async chat(userMessage, conversationHistory = []) {
try {
// 1. 获取可用工具列表
const toolsResult = await mcpService.listTools();
if (!toolsResult.success) {
throw new Error('获取工具列表失败');
}
const availableTools = toolsResult.data;
// 2. 构建对话历史
const messages = [
{
role: 'system',
content: this.getSystemPrompt(availableTools),
},
...conversationHistory.map(msg => ({
role: msg.isUser ? 'user' : 'assistant',
content: msg.content,
})),
{
role: 'user',
content: userMessage,
},
];
// 3. 调用LLM
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
// 注意这里需要配置API密钥
if (!LLM_CONFIG.apiKey) {
// 如果没有配置LLM使用简单的关键词匹配
logger.warn('LLMService', '未配置LLM API密钥使用简单匹配');
return await this.fallbackChat(userMessage);
}
const response = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: messages,
temperature: 0.7,
max_tokens: 1000,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const aiResponse = response.data.choices[0].message.content;
logger.info('LLMService', 'LLM响应', { response: aiResponse });
// 4. 解析LLM响应
// 如果LLM返回工具调用指令
try {
const toolCall = JSON.parse(aiResponse);
if (toolCall.tool && toolCall.arguments) {
// 调用MCP工具
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
if (!toolResult.success) {
return {
success: false,
error: toolResult.error,
};
}
// 5. 让LLM总结工具结果
const summaryMessages = [
...messages,
{
role: 'assistant',
content: aiResponse,
},
{
role: 'system',
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
},
];
const summaryResponse = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: summaryMessages,
temperature: 0.7,
max_tokens: 500,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const summary = summaryResponse.data.choices[0].message.content;
return {
success: true,
data: {
message: summary,
rawData: toolResult.data,
toolUsed: toolCall.tool,
},
};
}
} catch (parseError) {
// 不是JSON格式说明是直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
}
// 默认返回LLM的直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
} catch (error) {
logger.error('LLMService', 'chat error', error);
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 降级方案简单的关键词匹配当没有配置LLM时
*/
async fallbackChat(userMessage) {
logger.info('LLMService', '使用降级方案', { message: userMessage });
// 使用原有的简单匹配逻辑
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
const result = await mcpService.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
const result = await mcpService.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
return this.formatFallbackResponse(result, '概念搜索');
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
const result = await mcpService.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
return this.formatFallbackResponse(result, '涨停分析');
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
return this.formatFallbackResponse(result, '股票信息');
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
// 特殊处理茅台
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: '600519',
});
return this.formatFallbackResponse(result, '贵州茅台股票信息');
} else {
// 默认:搜索新闻
const result = await mcpService.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
}
}
/**
* 格式化降级响应
*/
formatFallbackResponse(result, action) {
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
data: {
message: `已为您完成${action},找到以下结果:`,
rawData: result.data,
},
};
}
/**
* 清除对话历史
*/
clearHistory() {
this.conversationHistory = [];
}
}
// 导出单例
export const llmService = new LLMService();
export default LLMService;

248
src/services/mcpService.js Normal file
View File

@@ -0,0 +1,248 @@
// src/services/mcpService.js
// MCP (Model Context Protocol) 服务层
// 用于与FastAPI后端的MCP工具进行交互
import axios from 'axios';
import { getApiBase } from '../utils/apiConfig';
import { logger } from '../utils/logger';
/**
* MCP API客户端
*/
class MCPService {
constructor() {
this.baseURL = `${getApiBase()}/mcp`;
this.client = axios.create({
baseURL: this.baseURL,
timeout: 60000, // 60秒超时MCP工具可能需要较长时间
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
logger.debug('MCPService', 'Request', {
url: config.url,
method: config.method,
data: config.data,
});
return config;
},
(error) => {
logger.error('MCPService', 'Request Error', error);
return Promise.reject(error);
}
);
// 响应拦截器
this.client.interceptors.response.use(
(response) => {
logger.debug('MCPService', 'Response', {
url: response.config.url,
status: response.status,
data: response.data,
});
return response.data;
},
(error) => {
logger.error('MCPService', 'Response Error', {
url: error.config?.url,
status: error.response?.status,
message: error.message,
});
return Promise.reject(error);
}
);
}
/**
* 列出所有可用的MCP工具
* @returns {Promise<Object>} 工具列表
*/
async listTools() {
try {
const response = await this.client.get('/tools');
return {
success: true,
data: response.tools || [],
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具列表失败',
};
}
}
/**
* 获取特定工具的定义
* @param {string} toolName - 工具名称
* @returns {Promise<Object>} 工具定义
*/
async getTool(toolName) {
try {
const response = await this.client.get(`/tools/${toolName}`);
return {
success: true,
data: response,
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具定义失败',
};
}
}
/**
* 调用MCP工具
* @param {string} toolName - 工具名称
* @param {Object} arguments - 工具参数
* @returns {Promise<Object>} 工具执行结果
*/
async callTool(toolName, toolArguments) {
try {
const response = await this.client.post('/tools/call', {
tool: toolName,
arguments: toolArguments,
});
return {
success: true,
data: response.data || response,
};
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || error.message || '工具调用失败',
};
}
}
/**
* 智能对话 - 根据用户输入自动选择合适的工具
* @param {string} userMessage - 用户消息
* @param {Array} conversationHistory - 对话历史(可选)
* @returns {Promise<Object>} AI响应
*/
async chat(userMessage, conversationHistory = []) {
try {
// 这里可以实现智能路由逻辑
// 根据用户输入判断应该调用哪个工具
// 示例:关键词匹配
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
return await this.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
return await this.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
return await this.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
return await this.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
} else {
// 默认:搜索新闻
return await this.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
}
} catch (error) {
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 工具类别枚举
*/
static TOOL_CATEGORIES = {
NEWS: 'news', // 新闻搜索
STOCK: 'stock', // 股票信息
CONCEPT: 'concept', // 概念板块
LIMIT_UP: 'limit_up', // 涨停分析
RESEARCH: 'research', // 研报搜索
ROADSHOW: 'roadshow', // 路演信息
FINANCIAL: 'financial', // 财务数据
TRADE: 'trade', // 交易数据
};
/**
* 常用工具快捷方式
*/
async searchNews(query, topK = 5, exactMatch = false) {
return await this.callTool('search_china_news', {
query,
top_k: topK,
exact_match: exactMatch,
});
}
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
return await this.callTool('search_concepts', {
query,
size,
sort_by: sortBy,
});
}
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
return await this.callTool('search_limit_up_stocks', {
query,
mode,
page_size: pageSize,
});
}
async getStockInfo(seccode) {
return await this.callTool('get_stock_basic_info', {
seccode,
});
}
async getStockConcepts(stockCode, size = 10) {
return await this.callTool('get_stock_concepts', {
stock_code: stockCode,
size,
});
}
async searchResearchReports(query, mode = 'hybrid', size = 5) {
return await this.callTool('search_research_reports', {
query,
mode,
size,
});
}
async getConceptStatistics(days = 7, minStockCount = 3) {
return await this.callTool('get_concept_statistics', {
days,
min_stock_count: minStockCount,
});
}
}
// 导出单例实例
export const mcpService = new MCPService();
// 导出类(供测试使用)
export default MCPService;

View File

@@ -310,6 +310,7 @@ class MockSocketService {
this.reconnectAttempts = 0;
this.customReconnectTimer = null;
this.failConnection = false; // 是否模拟连接失败
this.pushPaused = false; // 新增:暂停推送标志(保持连接)
}
/**
@@ -414,6 +415,7 @@ class MockSocketService {
// 清除所有定时器
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
const wasConnected = this.connected;
this.connected = false;
@@ -613,6 +615,12 @@ class MockSocketService {
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
const pushInterval = setInterval(() => {
// 检查是否暂停推送
if (this.pushPaused) {
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
return;
}
// 随机选择 1-burstCount 条消息
const count = Math.floor(Math.random() * burstCount) + 1;
@@ -642,22 +650,56 @@ class MockSocketService {
stopMockPush() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 暂停自动推送(保持连接和定时器运行)
*/
pausePush() {
this.pushPaused = true;
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
}
/**
* 恢复自动推送
*/
resumePush() {
this.pushPaused = false;
logger.info('mockSocketService', '▶️ Mock push resumed');
}
/**
* 查询推送暂停状态
* @returns {boolean} 是否已暂停
*/
isPushPaused() {
return this.pushPaused;
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
const notification = customData || {
type: 'trade_alert',
severity: 'info',
title: '测试消息',
message: '这是一条手动触发的测试消息',
timestamp: Date.now(),
autoClose: 5000,
// 如果传入自定义数据,直接使用(向后兼容)
if (customData) {
this.emit('new_event', customData);
logger.info('mockSocketService', 'Custom test notification sent', customData);
return;
}
// 默认发送新格式的测试通知(符合当前通知系统规范)
const notification = {
type: 'announcement', // 公告通知类型
priority: 'important', // 重要优先级30秒自动关闭
title: '🧪 测试通知',
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
id: `test_${Date.now()}`,
clickable: false,
};
this.emit('new_event', notification);
@@ -836,6 +878,27 @@ if (process.env.NODE_ENV === 'development') {
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
return attempts;
},
// 暂停自动推送(保持连接)
pausePush: () => {
mockSocketService.pausePush();
logger.info('mockSocketService', '⏸️ Auto push paused');
return true;
},
// 恢复自动推送
resumePush: () => {
mockSocketService.resumePush();
logger.info('mockSocketService', '▶️ Auto push resumed');
return true;
},
// 查看推送暂停状态
isPushPaused: () => {
const paused = mockSocketService.isPushPaused();
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
return paused;
},
};
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
@@ -845,6 +908,9 @@ if (process.env.NODE_ENV === 'development') {
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
}
export default mockSocketService;

View File

@@ -78,10 +78,93 @@ if (typeof window !== 'undefined') {
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
env: {
NODE_ENV: process.env.NODE_ENV,
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
REACT_APP_ENV: process.env.REACT_APP_ENV,
},
};
console.log('[Socket Diagnostics]', diagnostics);
return diagnostics;
}
},
// 手动订阅事件
subscribe: (options = {}) => {
const { eventType = 'all', importance = 'all' } = options;
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType,
importance,
onNewEvent: (event) => {
console.log('[Socket Debug] ✅ New event received:', event);
},
onSubscribed: (data) => {
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
},
});
} else {
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
}
},
// 测试连接质量
testConnection: () => {
console.log('[Socket Debug] Testing connection...');
const start = Date.now();
if (socket.emit) {
socket.emit('ping', { timestamp: start }, (response) => {
const latency = Date.now() - start;
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
});
} else {
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
}
},
// 检查配置是否正确
checkConfig: () => {
const config = {
socketType: SOCKET_TYPE,
useMock,
envVars: {
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
NODE_ENV: process.env.NODE_ENV,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
},
socketMethods: {
connect: typeof socket.connect,
disconnect: typeof socket.disconnect,
on: typeof socket.on,
emit: typeof socket.emit,
subscribeToEvents: typeof socket.subscribeToEvents,
},
};
console.log('[Socket Debug] Configuration Check:', config);
// 检查潜在问题
const issues = [];
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
issues.push('⚠️ WARNING: Using MOCK socket in production!');
}
if (!socket.subscribeToEvents) {
issues.push('❌ ERROR: subscribeToEvents method missing');
}
if (issues.length > 0) {
console.warn('[Socket Debug] Issues found:', issues);
} else {
console.log('[Socket Debug] ✅ No issues found');
}
return { config, issues };
},
};
console.log(
@@ -92,6 +175,190 @@ if (typeof window !== 'undefined') {
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
'color: #2196F3;'
);
// ========== 通知系统专用调试 API ==========
window.__NOTIFY_DEBUG__ = {
// 完整检查(配置+连接+订阅状态)
checkAll: () => {
console.log('\n==========【通知系统诊断】==========');
// 1. 检查 Socket 配置
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
console.log('\n✓ Socket 配置检查完成');
// 2. 检查连接状态
const status = window.__SOCKET_DEBUG__.getStatus();
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
// 3. 检查环境变量
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
// 4. 检查浏览器通知权限
const browserPermission = Notification?.permission || 'unsupported';
console.log('\n✓ 浏览器通知权限:', browserPermission);
// 5. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: {
type: SOCKET_TYPE,
connected: status.connected,
reconnectAttempts: status.reconnectAttempts,
},
env: socketCheck.config.envVars,
browserNotification: browserPermission,
issues: socketCheck.issues,
};
console.log('\n========== 诊断报告 ==========');
console.table(report);
if (report.issues.length > 0) {
console.warn('\n⚠ 发现问题:', report.issues);
} else {
console.log('\n✅ 系统正常,未发现问题');
}
// 提供修复建议
if (!status.connected) {
console.log('\n💡 修复建议:');
console.log(' 1. 检查网络连接');
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
console.log(' 3. 检查后端服务是否运行');
}
if (browserPermission === 'denied') {
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
}
console.log('\n====================================\n');
return report;
},
// 手动订阅事件(简化版)
subscribe: (eventType = 'all', importance = 'all') => {
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
},
// 模拟接收通知用于测试UI
testNotify: (type = 'announcement') => {
console.log('\n[通知调试] 模拟通知:', type);
const mockNotifications = {
announcement: {
id: `test_${Date.now()}`,
type: 'announcement',
priority: 'important',
title: '🧪 测试公告通知',
content: '这是一条测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
},
stock_alert: {
id: `test_${Date.now()}`,
type: 'stock_alert',
priority: 'urgent',
title: '🧪 测试股票预警',
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
publishTime: Date.now(),
pushTime: Date.now(),
},
event_alert: {
id: `test_${Date.now()}`,
type: 'event_alert',
priority: 'important',
title: '🧪 测试事件动向',
content: 'AI大模型新政策发布影响科技板块',
publishTime: Date.now(),
pushTime: Date.now(),
},
analysis_report: {
id: `test_${Date.now()}`,
type: 'analysis_report',
priority: 'normal',
title: '🧪 测试分析报告',
content: '2024年Q1市场策略报告已发布',
publishTime: Date.now(),
pushTime: Date.now(),
},
};
const notification = mockNotifications[type] || mockNotifications.announcement;
// 触发 new_event 事件
if (socket.emit) {
// 对于真实 Socket模拟服务端推送实际上客户端无法这样做仅用于Mock模式
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
}
// 直接触发事件监听器(如果是 Mock 模式)
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
socket.emit('new_event', notification);
console.log('✅ 已触发 Mock 通知事件');
}
console.log('通知数据:', notification);
return notification;
},
// 导出完整诊断报告
exportReport: () => {
const report = window.__NOTIFY_DEBUG__.checkAll();
// 生成可下载的 JSON
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-debug-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('✅ 诊断报告已导出');
return report;
},
// 快捷帮助
help: () => {
console.log('\n========== 通知系统调试 API ==========');
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
console.log('\n========== Socket 调试 API ==========');
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
console.log('====================================\n');
},
};
console.log(
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
'color: #FF9800;'
);
console.log(
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
'color: #FF9800;'
);
}
export default socket;

View File

@@ -64,6 +64,12 @@ class SocketService {
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
});
// 监听断开连接
@@ -142,11 +148,20 @@ class SocketService {
on(event, callback) {
if (!this.socket) {
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
console.warn(`[socketService] ❌ 无法监听事件 ${event}: Socket 未初始化`);
return;
}
this.socket.on(event, callback);
// 包装回调函数,添加日志
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
logger.info('socketService', `Event listener added: ${event}`);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
}
/**
@@ -337,13 +352,14 @@ class SocketService {
});
// 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// 先移除之前的监听器(避免重复)
this.socket.off('new_event');
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
// 添加新的监听器
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
@@ -355,7 +371,7 @@ class SocketService {
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
@@ -403,14 +419,26 @@ class SocketService {
/**
* 快捷方法:订阅所有类型的事件
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
* @returns {Function} 取消订阅的函数
*/
subscribeToAllEvents(onNewEvent) {
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
// 如果没有提供回调,添加一个默认的日志回调
const defaultCallback = (event) => {
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 事件数据:', event);
};
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent,
onNewEvent: onNewEvent || defaultCallback,
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
});
// 返回取消订阅的清理函数

144
src/store/api/eventsApi.js Normal file
View File

@@ -0,0 +1,144 @@
// src/store/api/eventsApi.js
// RTK Query API for Events - 事件数据获取 API
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { logger } from '../../utils/logger';
/**
* Events API Slice - 使用 RTK Query 管理事件数据
*
* 特性:
* - ✅ 自动缓存管理(按 queryKey 缓存)
* - ✅ 自动去重请求
* - ✅ 返回第一页刷新数据invalidateTags
* - ✅ 预加载支持prefetch
* - ✅ 统一在 Redux DevTools 中调试
* - ✅ 无需额外 Provider
*/
export const eventsApi = createApi({
reducerPath: 'eventsApi',
// 基础查询配置
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
// 可以在这里添加认证 token
// const token = localStorage.getItem('token');
// if (token) {
// headers.set('Authorization', `Bearer ${token}`);
// }
return headers;
},
}),
// 标签类型定义(用于缓存失效)
tagTypes: ['Events'],
// API 端点定义
endpoints: (builder) => ({
/**
* 获取分页事件列表
*
* @param {Object} params
* @param {number} params.page - 页码
* @param {number} params.per_page - 每页数量
* @param {string} params.mode - 显示模式vertical / four-row
* @param {string} params.sort - 排序方式
* @param {string} params.importance - 重要性筛选
* @param {string} params.q - 搜索关键词
* @param {string} params.date_range - 日期范围
* @param {string} params.industry_code - 行业代码
*
* @returns {Object} { events: Array, pagination: Object }
*/
getEvents: builder.query({
query: ({ page = 1, per_page = 5, mode = 'vertical', ...filters }) => {
logger.debug('eventsApi', 'getEvents 请求', {
page,
per_page,
mode,
filters,
});
return {
url: '/events',
params: {
page,
per_page,
...filters,
},
};
},
// 🔥 缓存标签:用于缓存失效
providesTags: (result, error, { page, mode }) => {
if (error) return [];
return [
{ type: 'Events', id: `${mode}-${page}` }, // 具体页面的标签
{ type: 'Events', id: `${mode}-LIST` }, // 模式的总标签
];
},
// 转换响应数据
transformResponse: (response) => {
logger.debug('eventsApi', 'getEvents 响应', {
eventsCount: response.data?.events?.length,
total: response.data?.pagination?.total,
});
if (!response.success || !response.data?.events) {
throw new Error('数据格式错误');
}
return {
events: response.data.events,
pagination: response.data.pagination || {},
};
},
// 错误处理
transformErrorResponse: (response) => {
logger.error('eventsApi', 'getEvents 失败', new Error(response.status));
return {
status: response.status,
message: response.data?.message || '获取事件数据失败',
};
},
// 🔥 keepUnusedDataFor: 缓存保留时间(秒)
keepUnusedDataFor: 600, // 10分钟
// 🔥 合并查询结果(用于无限滚动)
// serializeQueryArgs: ({ endpointName, queryArgs }) => {
// const { mode, ...filters } = queryArgs;
// return `${endpointName}(${mode})`;
// },
// merge: (currentCache, newItems) => {
// currentCache.events.push(...newItems.events);
// },
// forceRefetch: ({ currentArg, previousArg }) => {
// return currentArg.page !== previousArg?.page;
// },
}),
/**
* 预加载下一页(性能优化)
*
* 用法:
* dispatch(eventsApi.util.prefetch('getEvents', { page: 2, ... }))
*/
}),
});
// 导出自动生成的 Hooks
export const {
useGetEventsQuery,
useLazyGetEventsQuery, // 手动触发的版本
usePrefetch, // 预加载 Hook
} = eventsApi;
// 导出工具方法
export const {
util: { invalidateTags, prefetch },
} = eventsApi;

View File

@@ -7,6 +7,7 @@ import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice';
import posthogMiddleware from './middleware/posthogMiddleware';
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
export const store = configureStore({
reducer: {
@@ -16,6 +17,7 @@ export const store = configureStore({
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
@@ -29,7 +31,9 @@ export const store = configureStore({
'stock/fetchStockQuotes/fulfilled',
],
},
}).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件
})
.concat(posthogMiddleware) // ✅ PostHog 自动追踪中间件
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
});
export default store;

View File

@@ -103,7 +103,6 @@ const createDataReducers = (builder, asyncThunk, dataKey) => {
.addCase(asyncThunk.fulfilled, (state, action) => {
state.loading[dataKey] = false;
state[dataKey] = action.payload;
state.lastUpdated[dataKey] = new Date().toISOString();
})
.addCase(asyncThunk.rejected, (state, action) => {
state.loading[dataKey] = false;
@@ -157,63 +156,114 @@ export const fetchHotEvents = createAsyncThunk(
);
/**
* 获取动态新闻(客户端缓存 + 智能请求
* 获取动态新闻(客户端缓存 + 虚拟滚动
* 用于 DynamicNewsCard 组件
* @param {Object} params - 请求参数
* @param {string} params.mode - 显示模式('vertical' | 'four-row'
* @param {number} params.page - 页码
* @param {number} params.per_page - 每页数量
* @param {number} params.per_page - 每页数量(可选,不提供时自动根据 mode 计算)
* @param {boolean} params.clearCache - 是否清空缓存(默认 false
* @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false
* @param {string} params.sort - 排序方式new/hot
* @param {string} params.importance - 重要性筛选all/1/2/3/4/5
* @param {string} params.q - 搜索关键词
* @param {string} params.date_range - 时间范围
* @param {string} params.industry_code - 行业代码
*/
export const fetchDynamicNews = createAsyncThunk(
'communityData/fetchDynamicNews',
async ({
mode = 'vertical',
page = 1,
per_page = 5,
pageSize = 5, // 每页实际显示的数据量(用于计算索引
per_page, // 移除默认值,下面动态计算
pageSize, // 向后兼容(已废弃,使用 per_page
clearCache = false,
prependMode = false
prependMode = false,
sort = 'new',
importance,
q,
date_range, // 兼容旧格式(已废弃)
industry_code,
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date,
end_date,
recent_days
} = {}, { rejectWithValue }) => {
try {
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小
// - 平铺模式 (four-row): 30 条7.5行 × 4列提供充足的缓冲数据
// - 纵向模式 (vertical): 10 条(传统分页)
// 优先使用传入的 per_page其次使用 pageSize向后兼容最后根据 mode 计算
const finalPerPage = per_page || pageSize || (mode === 'four-row' ? 30 : 10);
// 构建筛选参数
const filters = {};
if (sort) filters.sort = sort;
if (importance && importance !== 'all') filters.importance = importance;
if (q) filters.q = q;
if (date_range) filters.date_range = date_range; // 兼容旧格式
if (industry_code) filters.industry_code = industry_code;
// 时间筛选参数
if (start_date) filters.start_date = start_date;
if (end_date) filters.end_date = end_date;
if (recent_days) filters.recent_days = recent_days;
logger.debug('CommunityData', '开始获取动态新闻', {
mode,
page,
per_page,
per_page: finalPerPage,
clearCache,
prependMode
prependMode,
filters
});
const response = await eventService.getEvents({
page,
per_page,
sort: 'new'
per_page: finalPerPage,
...filters
});
if (response.success && response.data?.events) {
logger.info('CommunityData', '动态新闻加载成功', {
count: response.data.events.length,
page: response.data.pagination?.page || page,
total: response.data.pagination?.total || 0
});
return {
events: response.data.events,
total: response.data.pagination?.total || 0,
per_page: finalPerPage
});
// 【兜底处理】支持多种 pagination 字段名pages (后端) / total_pages (旧Mock) / totalPages
const paginationData = response.data.pagination || {};
const calculatedTotalPages = paginationData.pages // ← 后端格式 (优先)
|| paginationData.total_pages // ← Mock 旧格式 (兼容)
|| paginationData.totalPages // ← 其他可能格式
|| Math.ceil((paginationData.total || 0) / finalPerPage); // ← 兜底计算
return {
mode,
events: response.data.events,
total: paginationData.total || 0,
totalPages: calculatedTotalPages,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
per_page: finalPerPage,
clearCache,
prependMode
};
}
logger.warn('CommunityData', '动态新闻返回数据为空', response);
// 【兜底处理】空数据情况也尝试读取 pagination
const emptyPaginationData = response.data?.pagination || {};
const emptyTotalPages = emptyPaginationData.pages || emptyPaginationData.total_pages || 0;
return {
mode,
events: [],
total: 0,
totalPages: emptyTotalPages,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
per_page: finalPerPage,
clearCache,
prependMode
prependMode,
isEmpty: true // 标记为空数据,用于边界条件处理
};
} catch (error) {
logger.error('CommunityData', '获取动态新闻失败', error);
@@ -269,42 +319,75 @@ export const toggleEventFollow = createAsyncThunk(
// ==================== Slice 定义 ====================
/**
* 【Redux State 架构设计】
*
* 核心原则:
* 1. **模式独立存储**: verticalEvents 和 fourRowEvents 完全独立
* - 原因:两种模式使用不同的 pageSize (10 vs 30),共享缓存会导致分页混乱
* - 代价:~50% 内存冗余(同一事件可能存在于两个数组)
* - 权衡:简化逻辑复杂度,避免 pageSize 切换时的边界计算问题
*
* 2. **数据去重**: 使用 Set 去重,防止重复事件
* - 场景1网络请求乱序慢请求后返回
* - 场景2定时刷新 + prepend 模式(新事件插入头部)
* - 场景3后端分页漂移新数据导致页码偏移
*
* 3. **追加模式 (append)**: 虚拟滚动必须使用累积数组
* - 原因:虚拟滚动需要完整数据计算 totalHeight
* - 对比传统分页每次替换数据page mode
*
* 4. **加载状态管理**: 分模式独立管理 loading/error
* - 避免模式切换时的加载状态冲突
*/
const communityDataSlice = createSlice({
name: 'communityData',
initialState: {
// 数据
popularKeywords: [],
hotEvents: [],
dynamicNews: [], // 动态新闻完整缓存列表
dynamicNewsTotal: 0, // 服务端总数量
eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } }
// 加载状态
// 【纵向模式】独立存储(传统分页 + 每页10条
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
verticalPagination: { // 分页元数据
total: 0, // 总记录数
total_pages: 0, // 总页数
current_page: 1, // 当前页码
per_page: 10 // 每页大小
},
// 【平铺模式】独立存储(虚拟滚动 + 每页30条
fourRowEvents: [], // 完整缓存列表(虚拟滚动的数据源)
fourRowPagination: { // 分页元数据
total: 0, // 总记录数
total_pages: 0, // 总页数
current_page: 1, // 当前页码
per_page: 30 // 每页大小
},
eventFollowStatus: {}, // 事件关注状态(全局共享){ [eventId]: { isFollowing: boolean, followerCount: number } }
// 加载状态(分模式管理)
loading: {
popularKeywords: false,
hotEvents: false,
dynamicNews: false
verticalEvents: false,
fourRowEvents: false
},
// 错误信息
// 错误信息(分模式管理)
error: {
popularKeywords: null,
hotEvents: null,
dynamicNews: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null,
dynamicNews: null
verticalEvents: null,
fourRowEvents: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
* 注意:dynamicNews 不使用 localStorage 缓存
* 注意:verticalEvents 和 fourRowEvents 不使用 localStorage 缓存
*/
clearCache: (state) => {
// 清除 localStorage
@@ -313,17 +396,19 @@ const communityDataSlice = createSlice({
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.dynamicNews = []; // 动态新闻也清除
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
state.lastUpdated.dynamicNews = null;
// 清除动态新闻数据(两个模式)
state.verticalEventsByPage = {};
state.fourRowEvents = [];
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'dynamicNews')
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'verticalEvents' | 'fourRowEvents')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
@@ -331,18 +416,21 @@ const communityDataSlice = createSlice({
if (type === 'popularKeywords') {
localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS);
state.popularKeywords = [];
state.lastUpdated.popularKeywords = null;
logger.info('CommunityData', '热门关键词缓存已清除');
} else if (type === 'hotEvents') {
localCacheManager.remove(CACHE_KEYS.HOT_EVENTS);
state.hotEvents = [];
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '热点事件缓存已清除');
} else if (type === 'dynamicNews') {
// dynamicNews 不使用 localStorage只清除 Redux state
state.dynamicNews = [];
state.lastUpdated.dynamicNews = null;
logger.info('CommunityData', '动态新闻数据已清除');
} else if (type === 'verticalEvents') {
// verticalEvents 不使用 localStorage只清除 Redux state
state.verticalEventsByPage = {};
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
logger.info('CommunityData', '纵向模式事件数据已清除');
} else if (type === 'fourRowEvents') {
// fourRowEvents 不使用 localStorage只清除 Redux state
state.fourRowEvents = [];
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
logger.info('CommunityData', '平铺模式事件数据已清除');
}
},
@@ -350,7 +438,7 @@ const communityDataSlice = createSlice({
* 预加载数据(用于应用启动时)
* 注意:这不是异步 action只是触发标记
*/
preloadData: (state) => {
preloadData: (_state) => {
logger.info('CommunityData', '准备预加载数据');
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
},
@@ -363,6 +451,17 @@ const communityDataSlice = createSlice({
const { eventId, isFollowing, followerCount } = action.payload;
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
},
/**
* 更新分页页码(用于缓存场景,无需 API 请求)
* @param {Object} action.payload - { mode, page }
*/
updatePaginationPage: (state, action) => {
const { mode, page } = action.payload;
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
state[paginationKey].current_page = page;
logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page });
}
},
@@ -372,101 +471,142 @@ const communityDataSlice = createSlice({
createDataReducers(builder, fetchHotEvents, 'hotEvents');
// dynamicNews 需要特殊处理(缓存 + 追加模式)
// 根据 mode 更新不同的 stateverticalEvents 或 fourRowEvents
builder
.addCase(fetchDynamicNews.pending, (state) => {
state.loading.dynamicNews = true;
state.error.dynamicNews = null;
.addCase(fetchDynamicNews.pending, (state, action) => {
const mode = action.meta.arg.mode || 'vertical';
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
state.loading[stateKey] = true;
state.error[stateKey] = null;
})
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
const { mode, events, total, page, per_page, clearCache, prependMode, isEmpty } = action.payload;
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
const totalKey = mode === 'four-row' ? 'fourRowTotal' : 'verticalTotal';
if (clearCache) {
// 清空缓存模式:直接替换
state.dynamicNews = events;
logger.debug('CommunityData', '清空缓存并加载新数据', {
count: events.length
});
} else if (prependMode) {
// 追加到头部模式(用于定时刷新):去重后插入头部
const existingIds = new Set(state.dynamicNews.map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...newEvents, ...state.dynamicNews];
logger.debug('CommunityData', '追加新数据到头部', {
newCount: newEvents.length,
totalCount: state.dynamicNews.length
});
} else {
// 智能插入模式:根据页码计算正确的插入位置
// 使用 pageSize每页显示量而不是 per_page请求数量
const startIndex = (page - 1) * (pageSize || per_page);
// 边界条件:空数据只记录日志,不更新 state保留现有数据
if (isEmpty || (events.length === 0 && !clearCache)) {
logger.info('CommunityData', `${mode} 模式返回空数据,跳过更新`);
state.loading[stateKey] = false;
state.error[stateKey] = '暂无更多数据'; // 设置提示信息供组件显示 toast
return; // 提前返回,不更新数据
}
// 判断插入模式
const isAppend = startIndex === state.dynamicNews.length;
const isReplace = startIndex < state.dynamicNews.length;
const isJump = startIndex > state.dynamicNews.length;
// 🔍 调试:收到数据
console.log('%c[Redux] fetchDynamicNews.fulfilled 收到数据', 'color: #10B981; font-weight: bold;', {
mode,
stateKey,
eventsCount: events.length,
total,
page,
clearCache,
prependMode,
'state[stateKey] 类型': Array.isArray(state[stateKey]) ? 'Array' : 'Object',
'state[stateKey] 之前': Array.isArray(state[stateKey])
? `数组长度: ${state[stateKey].length}`
: `对象页数: ${Object.keys(state[stateKey] || {}).length}`,
});
// 只在 append 模式下去重(避免定时刷新重复)
// 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失)
if (isAppend) {
// Append 模式:连续加载,需要去重
const existingIds = new Set(
state.dynamicNews
.filter(e => e !== null)
.map(e => e.id)
);
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...state.dynamicNews, ...newEvents];
logger.debug('CommunityData', '连续追加数据(去重)', {
/**
* 【数据存储逻辑】根据模式选择不同的存储策略
*
* 纵向模式vertical页码映射存储
* - clearCache=true: 清空所有页存储新页第1页专用
* - clearCache=false: 存储到对应页码第2、3、4...页)
* - 优点:每页独立,不受去重影响,支持缓存
*
* 平铺模式four-row去重追加存储
* - clearCache=true: 直接替换(用于刷新)
* - prependMode=true: 去重后插入头部(定时刷新)
* - 默认:去重后追加到末尾(无限滚动)
* - 优点:累积显示,支持虚拟滚动
*/
if (mode === 'vertical') {
// 【纵向模式】页码映射存储
if (clearCache) {
// 第1页清空所有页只保留新页
state.verticalEventsByPage = { [page]: events };
logger.debug('CommunityData', `清空缓存并加载第${page}页 (纵向模式)`, {
count: events.length
});
console.log('%c[Redux] 纵向模式 clearCache清空所有页', 'color: #10B981; font-weight: bold;', {
page,
eventsCount: events.length
});
} else {
// 其他页:存储到对应页码
state.verticalEventsByPage = state.verticalEventsByPage || {};
state.verticalEventsByPage[page] = events;
logger.debug('CommunityData', `存储第${page}页数据 (纵向模式)`, {
page,
count: events.length,
totalPages: Object.keys(state.verticalEventsByPage || {}).length
});
console.log('%c[Redux] 纵向模式追加页面', 'color: #10B981; font-weight: bold;', {
page,
eventsCount: events.length,
cachedPages: Object.keys(state.verticalEventsByPage || {})
});
}
} else if (mode === 'four-row') {
// 【平铺模式】去重追加存储
if (clearCache) {
// 清空缓存模式:直接替换
state.fourRowEvents = events;
logger.debug('CommunityData', `清空缓存并加载新数据 (平铺模式)`, {
count: events.length
});
console.log('%c[Redux] 平铺模式 clearCache直接替换数据', 'color: #10B981; font-weight: bold;', {
eventsCount: events.length
});
} else if (prependMode) {
// 追加到头部模式(用于定时刷新):去重后插入头部
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
state.fourRowEvents = [...newEvents, ...(state.fourRowEvents || [])];
logger.debug('CommunityData', `追加新数据到头部 (平铺模式)`, {
newCount: newEvents.length,
totalCount: state.fourRowEvents.length
});
} else {
// 默认追加模式:去重后追加到末尾(用于虚拟滚动加载下一页)
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
state.fourRowEvents = [...(state.fourRowEvents || []), ...newEvents];
logger.debug('CommunityData', `追加新数据(去重,平铺模式)`, {
page,
startIndex,
endIndex: startIndex + newEvents.length,
originalEventsCount: events.length,
newEventsCount: newEvents.length,
filteredCount: events.length - newEvents.length,
totalCount: state.dynamicNews.length
});
} else if (isReplace) {
// 替换模式:直接覆盖,不去重
const endIndex = startIndex + events.length;
const before = state.dynamicNews.slice(0, startIndex);
const after = state.dynamicNews.slice(endIndex);
state.dynamicNews = [...before, ...events, ...after];
logger.debug('CommunityData', '替换重叠数据(不去重)', {
page,
startIndex,
endIndex,
eventsCount: events.length,
beforeLength: before.length,
afterLength: after.length,
totalCount: state.dynamicNews.length
});
} else {
// 跳页模式:填充间隔,不去重
const gap = startIndex - state.dynamicNews.length;
const fillers = Array(gap).fill(null);
state.dynamicNews = [...state.dynamicNews, ...fillers, ...events];
logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', {
page,
startIndex,
endIndex: startIndex + events.length,
gap,
eventsCount: events.length,
totalCount: state.dynamicNews.length
totalCount: state.fourRowEvents.length
});
}
}
state.dynamicNewsTotal = total;
state.loading.dynamicNews = false;
state.lastUpdated.dynamicNews = new Date().toISOString();
// 【元数据存储】存储完整的 pagination 对象
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
const finalPerPage = per_page || (mode === 'four-row' ? 30 : 10); // 兜底默认值
state[paginationKey] = {
total: total,
total_pages: action.payload.totalPages || Math.ceil(total / finalPerPage),
current_page: page,
per_page: finalPerPage
};
console.log('%c[Redux] 更新分页元数据', 'color: #8B5CF6; font-weight: bold;', {
mode,
pagination: state[paginationKey]
});
state.loading[stateKey] = false;
})
.addCase(fetchDynamicNews.rejected, (state, action) => {
state.loading.dynamicNews = false;
state.error.dynamicNews = action.payload;
logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload));
const mode = action.meta.arg.mode || 'vertical';
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
state.loading[stateKey] = false;
state.error[stateKey] = action.payload;
logger.error('CommunityData', `${stateKey} 加载失败`, new Error(action.payload));
})
// toggleEventFollow
.addCase(toggleEventFollow.fulfilled, (state, action) => {
@@ -474,7 +614,7 @@ const communityDataSlice = createSlice({
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount });
})
.addCase(toggleEventFollow.rejected, (state, action) => {
.addCase(toggleEventFollow.rejected, (_state, action) => {
logger.error('CommunityData', 'toggleEventFollow rejected', action.payload);
});
}
@@ -482,46 +622,60 @@ const communityDataSlice = createSlice({
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions;
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
// 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectDynamicNews = (state) => state.communityData.dynamicNews;
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
// 纵向模式数据选择器
export const selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage;
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
export const selectVerticalCachedPageCount = (state) => Object.keys(state.communityData.verticalEventsByPage || {}).length;
// 平铺模式数据选择器
export const selectFourRowEvents = (state) => state.communityData.fourRowEvents;
export const selectFourRowPagination = (state) => state.communityData.fourRowPagination;
export const selectFourRowCachedCount = (state) => (state.communityData.fourRowEvents || []).length;
// 向后兼容的选择器(已废弃,建议使用 selectVerticalPagination.total
export const selectVerticalTotal = (state) => state.communityData.verticalPagination?.total || 0;
export const selectFourRowTotal = (state) => state.communityData.fourRowPagination?.total || 0;
// 组合选择器
export const selectPopularKeywordsWithLoading = (state) => ({
data: state.communityData.popularKeywords,
loading: state.communityData.loading.popularKeywords,
error: state.communityData.error.popularKeywords,
lastUpdated: state.communityData.lastUpdated.popularKeywords
error: state.communityData.error.popularKeywords
});
export const selectHotEventsWithLoading = (state) => ({
data: state.communityData.hotEvents,
loading: state.communityData.loading.hotEvents,
error: state.communityData.error.hotEvents,
lastUpdated: state.communityData.lastUpdated.hotEvents
error: state.communityData.error.hotEvents
});
export const selectDynamicNewsWithLoading = (state) => ({
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
loading: state.communityData.loading.dynamicNews,
error: state.communityData.error.dynamicNews,
total: state.communityData.dynamicNewsTotal, // 服务端总数量
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null
lastUpdated: state.communityData.lastUpdated.dynamicNews
// 纵向模式数据 + 加载状态选择器
export const selectVerticalEventsWithLoading = (state) => ({
data: state.communityData.verticalEventsByPage, // 页码映射 { 1: [...], 2: [...] }
loading: state.communityData.loading.verticalEvents,
error: state.communityData.error.verticalEvents,
pagination: state.communityData.verticalPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
total: state.communityData.verticalPagination?.total || 0, // 向后兼容:服务端总数量
cachedPageCount: Object.keys(state.communityData.verticalEventsByPage || {}).length // 已缓存页数
});
// 工具函数:检查数据是否需要刷新(超过指定时间)
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
if (!lastUpdated) return true;
const elapsed = Date.now() - new Date(lastUpdated).getTime();
return elapsed > thresholdMinutes * 60 * 1000;
};
// 平铺模式数据 + 加载状态选择器
export const selectFourRowEventsWithLoading = (state) => ({
data: state.communityData.fourRowEvents, // 完整缓存列表
loading: state.communityData.loading.fourRowEvents,
error: state.communityData.error.fourRowEvents,
pagination: state.communityData.fourRowPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
total: state.communityData.fourRowPagination?.total || 0, // 向后兼容:服务端总数量
cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量
});
export default communityDataSlice.reducer;

98
src/utils/colorUtils.js Normal file
View File

@@ -0,0 +1,98 @@
// src/utils/colorUtils.js
// 颜色工具函数 - 根据涨跌幅动态计算颜色深浅
/**
* 根据涨跌幅获取颜色(深浅动态变化)
* @param {number} change - 涨跌幅百分比
* @returns {string} Chakra UI 颜色值
*/
export const getChangeColor = (change) => {
if (change === null || change === undefined || isNaN(change)) {
return 'gray.500';
}
const absChange = Math.abs(change);
if (change > 0) {
// 涨:红色系,根据涨幅深浅
if (absChange >= 9) return 'red.900'; // 涨停或接近涨停
if (absChange >= 7) return 'red.800';
if (absChange >= 5) return 'red.700';
if (absChange >= 3) return 'red.600';
if (absChange >= 2) return 'red.500';
if (absChange >= 1) return 'red.400';
return 'red.300'; // 微涨
} else if (change < 0) {
// 跌:绿色系,根据跌幅深浅
if (absChange >= 9) return 'green.900'; // 跌停或接近跌停
if (absChange >= 7) return 'green.800';
if (absChange >= 5) return 'green.700';
if (absChange >= 3) return 'green.600';
if (absChange >= 2) return 'green.500';
if (absChange >= 1) return 'green.400';
return 'green.300'; // 微跌
}
return 'gray.500'; // 平盘
};
/**
* 获取涨跌幅背景渐变色(用于精简模式卡片)
* @param {number} change - 涨跌幅百分比
* @param {boolean} useDark - 是否使用深色模式
* @returns {string} Chakra UI bgGradient 值
*/
export const getChangeBackgroundGradient = (change, useDark = false) => {
if (change === null || change === undefined || isNaN(change)) {
return 'linear(to-br, gray.50, gray.100)';
}
const absChange = Math.abs(change);
if (change > 0) {
// 涨:红色渐变背景
if (absChange >= 9) return 'linear(to-br, red.100, red.200)';
if (absChange >= 7) return 'linear(to-br, red.50, red.150)';
if (absChange >= 5) return 'linear(to-br, red.50, red.100)';
if (absChange >= 3) return 'linear(to-br, red.50, red.100)';
return 'linear(to-br, red.50, red.50)';
} else if (change < 0) {
// 跌:绿色渐变背景
if (absChange >= 9) return 'linear(to-br, green.100, green.200)';
if (absChange >= 7) return 'linear(to-br, green.50, green.150)';
if (absChange >= 5) return 'linear(to-br, green.50, green.100)';
if (absChange >= 3) return 'linear(to-br, green.50, green.100)';
return 'linear(to-br, green.50, green.50)';
}
return 'linear(to-br, gray.50, gray.100)';
};
/**
* 获取涨跌幅边框颜色
* @param {number} change - 涨跌幅百分比
* @returns {string} Chakra UI 颜色值
*/
export const getChangeBorderColor = (change) => {
if (change === null || change === undefined || isNaN(change)) {
return 'gray.300';
}
const absChange = Math.abs(change);
if (change > 0) {
if (absChange >= 9) return 'red.700';
if (absChange >= 7) return 'red.600';
if (absChange >= 5) return 'red.500';
if (absChange >= 3) return 'red.400';
return 'red.300';
} else if (change < 0) {
if (absChange >= 9) return 'green.700';
if (absChange >= 7) return 'green.600';
if (absChange >= 5) return 'green.500';
if (absChange >= 3) return 'green.400';
return 'green.300';
}
return 'gray.300';
};

1038
src/views/AgentChat/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
// src/views/AgentChat/index.js
// Agent聊天页面
import React from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { ChatInterfaceV2 } from '../../components/ChatBot';
/**
* Agent聊天页面
* 提供基于MCP的AI助手对话功能
*/
const AgentChat = () => {
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
return (
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
<Container maxW="container.xl" h="100%">
<VStack spacing={6} align="stretch" h="100%">
{/* 页面标题 */}
<Box>
<Heading size="lg" mb={2}>AI投资助手</Heading>
<Text color="gray.600" fontSize="sm">
基于MCP协议的智能投资顾问支持股票查询新闻搜索概念分析等多种功能
</Text>
</Box>
{/* 聊天界面 */}
<Box
flex="1"
bg={cardBg}
borderRadius="xl"
boxShadow="xl"
overflow="hidden"
h="calc(100vh - 300px)"
minH="600px"
>
<ChatInterfaceV2 />
</Box>
</VStack>
</Container>
</Box>
);
};
export default AgentChat;

View File

@@ -0,0 +1,857 @@
// src/views/AgentChat/index_v3.js
// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Button,
Avatar,
Heading,
Divider,
Spinner,
Badge,
useColorModeValue,
useToast,
Progress,
Fade,
Collapse,
useDisclosure,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
} from '@chakra-ui/react';
import {
FiSend,
FiSearch,
FiPlus,
FiMessageSquare,
FiTrash2,
FiMoreVertical,
FiRefreshCw,
FiDownload,
FiCpu,
FiUser,
FiZap,
FiClock,
} from 'react-icons/fi';
import { useAuth } from '@contexts/AuthContext';
import { PlanCard } from '@components/ChatBot/PlanCard';
import { StepResultCard } from '@components/ChatBot/StepResultCard';
import { logger } from '@utils/logger';
import axios from 'axios';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* Agent聊天页面 V3
*/
const AgentChatV3 = () => {
const { user } = useAuth(); // 获取当前用户信息
const toast = useToast();
// 会话相关状态
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
// 消息相关状态
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true });
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const sidebarBg = useColorModeValue('white', 'gray.800');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const activeBg = useColorModeValue('blue.50', 'blue.900');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
// ==================== 会话管理函数 ====================
// 加载会话列表
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
logger.info('会话列表加载成功', response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
toast({
title: '加载失败',
description: '无法加载会话列表',
status: 'error',
duration: 3000,
});
} finally {
setIsLoadingSessions(false);
}
};
// 加载会话历史
const loadSessionHistory = async (sessionId) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
// 将历史记录转换为消息格式
const formattedMessages = history.map((msg, idx) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
logger.info('会话历史加载成功', formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
toast({
title: '加载失败',
description: '无法加载会话历史',
status: 'error',
duration: 3000,
});
}
};
// 创建新会话
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}我是价小前北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
timestamp: new Date().toISOString(),
},
]);
};
// 切换会话
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
// 删除会话需要后端API支持
const deleteSession = async (sessionId) => {
// TODO: 实现删除会话的后端API
toast({
title: '删除会话',
description: '此功能尚未实现',
status: 'info',
duration: 2000,
});
};
// ==================== 消息处理函数 ====================
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
// 权限检查
if (user?.id !== 'max') {
toast({
title: '权限不足',
description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
let currentPlan = null;
let stepResults = [];
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 2. 调用后端API非流式
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
session_id: currentSessionId,
});
// 移除思考消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
// 更新 session_id如果是新会话
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
// 显示执行计划
if (data.plan) {
currentPlan = data.plan;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
}
// 显示执行步骤
if (data.steps && data.steps.length > 0) {
stepResults = data.steps;
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: stepResults,
timestamp: new Date().toISOString(),
});
setCurrentProgress(70);
}
// 移除执行中消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
// 重新加载会话列表
loadSessions();
}
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
createNewSession();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 初始化 ====================
useEffect(() => {
if (user) {
loadSessions();
createNewSession();
}
}, [user]);
// ==================== 渲染 ====================
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
// 筛选会话
const filteredSessions = sessions.filter(
(session) =>
!searchQuery ||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Flex h="calc(100vh - 80px)" bg={bgColor}>
{/* 左侧会话列表 */}
<Collapse in={isSidebarOpen} animateOpacity>
<Box
w="300px"
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
h="100%"
display="flex"
flexDirection="column"
>
{/* 侧边栏头部 */}
<Box p={4} borderBottom="1px" borderColor={borderColor}>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
w="100%"
onClick={createNewSession}
size="sm"
>
新建对话
</Button>
{/* 搜索框 */}
<InputGroup mt={3} size="sm">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray.300" />
</InputLeftElement>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</Box>
{/* 会话列表 */}
<VStack
flex="1"
overflowY="auto"
spacing={0}
align="stretch"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '3px',
},
}}
>
{isLoadingSessions ? (
<Flex justify="center" align="center" h="200px">
<Spinner />
</Flex>
) : filteredSessions.length === 0 ? (
<Flex justify="center" align="center" h="200px" direction="column">
<FiMessageSquare size={32} color="gray" />
<Text mt={2} fontSize="sm" color="gray.500">
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
</Text>
</Flex>
) : (
filteredSessions.map((session) => (
<Box
key={session.session_id}
p={3}
cursor="pointer"
bg={currentSessionId === session.session_id ? activeBg : 'transparent'}
_hover={{ bg: hoverBg }}
borderBottom="1px"
borderColor={borderColor}
onClick={() => switchSession(session.session_id)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
{session.last_message || '新对话'}
</Text>
<HStack spacing={2} fontSize="xs" color="gray.500">
<FiClock size={12} />
<Text>
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
<Badge colorScheme="blue" fontSize="xx-small">
{session.message_count}
</Badge>
</HStack>
</VStack>
<Menu>
<MenuButton
as={IconButton}
icon={<FiMoreVertical />}
size="xs"
variant="ghost"
onClick={(e) => e.stopPropagation()}
/>
<MenuList>
<MenuItem
icon={<FiTrash2 />}
color="red.500"
onClick={(e) => {
e.stopPropagation();
deleteSession(session.session_id);
}}
>
删除对话
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))
)}
</VStack>
{/* 用户信息 */}
<Box p={4} borderTop="1px" borderColor={borderColor}>
<HStack spacing={3}>
<Avatar size="sm" name={user?.nickname} src={user?.avatar} />
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="medium">
{user?.nickname || '未登录'}
</Text>
<Text fontSize="xs" color="gray.500">
{user?.id || 'anonymous'}
</Text>
</VStack>
</HStack>
</Box>
</Box>
</Collapse>
{/* 主聊天区域 */}
<Flex flex="1" direction="column" h="100%">
{/* 聊天头部 */}
<Box bg={chatBg} borderBottom="1px" borderColor={borderColor} px={6} py={4}>
<HStack justify="space-between">
<HStack spacing={4}>
<IconButton
icon={<FiMessageSquare />}
size="sm"
variant="ghost"
aria-label="切换侧边栏"
onClick={toggleSidebar}
/>
<Avatar size="md" bg="blue.500" icon={<FiCpu fontSize="1.5rem" />} />
<VStack align="start" spacing={0}>
<Heading size="md">价小前投研</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>
💡 试试这些问题
</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" src={userAvatar} icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1">
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default AgentChatV3;

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsCard.js
// 横向滚动事件卡片组件(实时要闻·动态追踪)
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
@@ -16,21 +16,41 @@ import {
Badge,
Center,
Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useColorModeValue,
useToast
useToast,
useDisclosure,
Switch,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import { TimeIcon, BellIcon } from '@chakra-ui/icons';
import { useNotification } from '../../../contexts/NotificationContext';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
import PaginationControl from './DynamicNewsCard/PaginationControl';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
import UnifiedSearchBox from './UnifiedSearchBox';
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
import {
fetchDynamicNews,
toggleEventFollow,
selectEventFollowStatus,
selectVerticalEventsWithLoading,
selectFourRowEventsWithLoading
} from '../../../store/slices/communityDataSlice';
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
// 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0;
/**
* 实时要闻·动态追踪 - 事件展示卡片组件
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
* @param {boolean} loading - 加载状态
* @param {number} total - 服务端总数量
* @param {number} cachedCount - 已缓存数量
* @param {Object} filters - 筛选条件
* @param {Array} popularKeywords - 热门关键词
* @param {Date} lastUpdateTime - 最后更新时间
@@ -38,13 +58,10 @@ import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '..
* @param {Function} onSearchFocus - 搜索框获得焦点回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} trackingFunctions - PostHog 追踪函数集合
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
allCachedEvents = [],
loading,
total = 0,
cachedCount = 0,
filters = {},
popularKeywords = [],
lastUpdateTime,
@@ -52,6 +69,7 @@ const DynamicNewsCard = forwardRef(({
onSearchFocus,
onEventClick,
onViewDetail,
trackingFunctions = {},
...rest
}, ref) => {
const dispatch = useDispatch();
@@ -59,305 +77,450 @@ const DynamicNewsCard = forwardRef(({
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification();
// 固定模式状态
const [isFixedMode, setIsFixedMode] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const cardHeaderRef = useRef(null);
const cardBodyRef = useRef(null);
// 导航栏和页脚固定高度
const NAVBAR_HEIGHT = 64; // 主导航高度
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
const FOOTER_HEIGHT = 80; // 页脚高度(优化后)
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
// 本地状态:模式(先初始化,后面会被 usePagination 更新)
const [currentMode, setCurrentMode] = useState('vertical');
// 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined
const verticalData = useSelector(selectVerticalEventsWithLoading) || {};
const fourRowData = useSelector(selectFourRowEventsWithLoading) || {};
// 🔍 调试:从 Redux 读取数据
console.log('%c[DynamicNewsCard] 从 Redux 读取数据', 'color: #3B82F6; font-weight: bold;', {
currentMode,
'verticalData.data type': typeof verticalData.data,
'verticalData.data keys': verticalData.data ? Object.keys(verticalData.data) : [],
'verticalData.total': verticalData.total,
'verticalData.cachedPageCount': verticalData.cachedPageCount,
'verticalData.loading': verticalData.loading,
'fourRowData.data?.length': fourRowData.data?.length || 0,
'fourRowData.total': fourRowData.total,
});
// 根据模式选择数据源
// 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...]
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
const {
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
loading = false,
error = null,
pagination, // 分页元数据
total = 0, // 向后兼容
cachedCount = 0,
cachedPageCount = 0
} = modeData;
// 传递给 usePagination 的数据
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
// 🔍 调试:选择的数据源
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
mode: currentMode,
'allCachedEventsByPage': allCachedEventsByPage ? Object.keys(allCachedEventsByPage) : 'undefined',
'allCachedEvents?.length': allCachedEvents?.length,
total,
cachedCount,
cachedPageCount,
loading,
error
});
// 🔍 调试:记录每次渲染
dynamicNewsCardRenderCount++;
console.log(`%c🔍 [DynamicNewsCard] 渲染 #${dynamicNewsCardRenderCount} - mode=${currentMode}, allCachedEvents.length=${allCachedEvents?.length || 0}, total=${total}`, 'color: #FF9800; font-weight: bold; font-size: 14px;');
// 关注按钮点击处理
const handleToggleFollow = useCallback((eventId) => {
dispatch(toggleEventFollow(eventId));
}, [dispatch]);
// 通知开关处理
const handleNotificationToggle = useCallback(async () => {
if (browserPermission === 'granted') {
// 已授权,提示用户去浏览器设置中关闭
toast({
title: '已开启通知',
description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置',
status: 'info',
duration: 5000,
isClosable: true,
});
} else {
// 未授权,请求权限
await requestBrowserPermission();
}
}, [browserPermission, requestBrowserPermission, toast]);
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
const [currentPage, setCurrentPage] = useState(1); // 当前页码
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
// 根据模式决定每页显示数量
const pageSize = mode === 'carousel' ? 5 : 10;
// 弹窗状态(用于四排模式)
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
const [modalEvent, setModalEvent] = useState(null);
// 计算总页数(基于服务端总数据量)
const totalPages = Math.ceil(total / pageSize) || 1;
// 初始化标记 - 确保初始加载只执行一次
const hasInitialized = useRef(false);
// 追踪是否已自动选中过首个事件
const hasAutoSelectedFirstEvent = useRef(false);
// 追踪筛选条件 useEffect 是否是第一次渲染(避免初始加载时重复请求)
const isFirstRenderForFilters = useRef(true);
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 使用分页 Hook
const {
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
displayEvents, // 当前显示的事件列表
handlePageChange,
handleModeToggle,
loadNextPage, // 加载下一页
loadPrevPage // 加载上一页
} = usePagination({
allCachedEventsByPage, // 纵向模式:页码映射
allCachedEvents, // 平铺模式:数组
pagination, // 分页元数据对象
total, // 向后兼容
cachedCount,
dispatch,
toast,
filters, // 传递筛选条件
initialMode: currentMode // 传递当前显示模式
});
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
}, [allCachedEvents, currentPage, pageSize]);
// 翻页处理(智能预加载)
const handlePageChange = useCallback(async (newPage) => {
// 🔍 诊断日志 - 记录翻页开始状态
console.log('[handlePageChange] 开始翻页', {
currentPage,
newPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 0. 首先检查目标页数据是否已完整缓存
const targetPageStartIndex = (newPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
const isTargetPageCached = validTargetData.length >= expectedCount;
console.log('[handlePageChange] 目标页缓存检查', {
newPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
// 2. 计算预加载范围
let preloadRange;
if (isSequentialNavigation) {
// 连续翻页前后各2页共5页
const start = Math.max(1, newPage - 2);
const end = Math.min(totalPages, newPage + 2);
preloadRange = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
);
} else {
// 跳转翻页:只加载当前页
preloadRange = [newPage];
}
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
const missingPages = preloadRange.filter(page => {
const pageStartIndex = (page - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
// 如果该页超出数组范围,说明未缓存
if (pageEndIndex > allCachedEvents.length) {
console.log(`[missingPages] 页面${page}超出数组范围`, {
pageStartIndex,
pageEndIndex,
allCachedEventsLength: allCachedEvents.length
});
return true;
}
// 检查该页的数据是否包含 null 占位符或数据不足
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
const validData = pageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - pageStartIndex);
const hasNullOrIncomplete = validData.length < expectedCount;
console.log(`[missingPages] 页面${page}检查`, {
pageStartIndex,
pageEndIndex,
pageDataLength: pageData.length,
validDataLength: validData.length,
expectedCount,
hasNullOrIncomplete
});
return hasNullOrIncomplete;
});
console.log('[handlePageChange] 缺失页面检测完成', {
preloadRange,
missingPages,
missingPagesCount: missingPages.length
});
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
currentPage,
newPage,
缺失页面: missingPages,
目标页已缓存: true
});
// 立即切换页码(用户无感知延迟)
setCurrentPage(newPage);
// 在后台静默预加载其他缺失页面(拆分为单页请求)
try {
console.log('[DynamicNewsCard] 开始后台预加载', {
缺失页面: missingPages,
每页数量: pageSize
});
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
}
console.log('[DynamicNewsCard] 后台预加载全部完成', {
预加载页面: missingPages
});
} catch (error) {
console.error('[DynamicNewsCard] 后台预加载失败', error);
// 静默失败,不影响用户体验
}
return; // 提前返回,不执行下面的加载逻辑
}
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
if (missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页未缓存显示loading', {
currentPage,
newPage,
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
预加载范围: preloadRange,
缺失页面: missingPages,
每页数量: pageSize,
目标页已缓存: false
});
try {
// 设置加载状态(显示"正在加载第X页..."
setLoadingPage(newPage);
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
console.log(`[DynamicNewsCard] 开始加载第 ${page}`);
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize, // 传递原始 pageSize用于正确计算索引
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
}
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
缺失页面: missingPages
});
// 数据加载成功后才更新当前页码
setCurrentPage(newPage);
} catch (error) {
console.error('[DynamicNewsCard] 翻页加载失败', error);
// 显示错误提示
toast({
title: '加载失败',
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
status: 'error',
duration: 3000,
isClosable: true,
position: 'top'
});
// 加载失败时不更新页码,保持在当前页
} finally {
// 清除加载状态
setLoadingPage(null);
}
} else if (missingPages.length === 0) {
// 只有在确实不需要加载时才直接切换
console.log('[handlePageChange] 无需加载,直接切换', {
currentPage,
newPage,
preloadRange,
missingPages,
reason: '所有页面均已缓存'
});
setCurrentPage(newPage);
} else {
// 理论上不应该到这里missingPages.length > 0 但 hasMore=false
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
missingPages,
hasMore,
currentPage,
newPage,
total,
cachedCount
});
// 尝试切换页码,但可能会显示空数据
setCurrentPage(newPage);
// 同步 mode 到 currentMode
useEffect(() => {
setCurrentMode(mode);
}, [mode]);
// 监听 error 状态,显示空数据提示
useEffect(() => {
if (error && error.includes('暂无更多数据')) {
toast({
title: '数据不完整',
description: `${newPage} 页数据可能不完整`,
status: 'warning',
title: '提示',
description: error,
status: 'info',
duration: 2000,
isClosable: true,
position: 'top'
});
}
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
}, [error, toast]);
// 模式切换处理
const handleModeToggle = useCallback((newMode) => {
if (newMode === mode) return;
// 四排模式的事件点击处理(打开弹窗)
const handleFourRowEventClick = useCallback((event) => {
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
setMode(newMode);
setCurrentPage(1);
// 🎯 追踪事件详情打开
if (trackingFunctions.trackNewsDetailOpened) {
trackingFunctions.trackNewsDetailOpened({
eventId: event.id,
eventTitle: event.title,
importance: event.importance,
source: 'four_row_mode',
displayMode: 'modal',
timestamp: new Date().toISOString(),
});
}
const newPageSize = newMode === 'carousel' ? 5 : 10;
setModalEvent(event);
onModalOpen();
}, [onModalOpen, trackingFunctions]);
// 检查第1页的数据是否完整排除 null
const firstPageData = allCachedEvents.slice(0, newPageSize);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
// 初始加载 - 只在组件首次挂载且对应模式数据为空时执行
useEffect(() => {
// 添加防抖:如果已经初始化,不再执行
if (hasInitialized.current) return;
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
const isDataEmpty = currentMode === 'vertical'
? Object.keys(allCachedEventsByPage || {}).length === 0
: (allCachedEvents?.length || 0) === 0;
if (isDataEmpty) {
hasInitialized.current = true;
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
mode: mode, // 传递当前模式
per_page: pageSize,
pageSize: pageSize, // 传递 pageSize 确保索引计算一致
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}
// 如果第1页数据完整不发起请求直接切换
}, [mode, allCachedEvents, total, dispatch]);
}, [dispatch, currentMode, mode, pageSize]); // 移除 allCachedEventsByPage, allCachedEvents 依赖,避免数据更新触发重复请求
// 初始加载
// 监听筛选条件变化 - 清空缓存并重新请求数据
useEffect(() => {
if (allCachedEvents.length === 0) {
// 跳过初始加载(由上面的 useEffect 处理)
if (!hasInitialized.current) return;
// 跳过第一次渲染(避免与初始加载 useEffect 重复)
if (isFirstRenderForFilters.current) {
isFirstRenderForFilters.current = false;
return;
}
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', filters);
// 筛选条件改变时清空对应模式的缓存并从第1页开始加载
dispatch(fetchDynamicNews({
mode: mode, // 传递当前模式
per_page: pageSize,
pageSize: pageSize,
clearCache: true, // 清空缓存
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}, [
filters.sort,
filters.importance,
filters.q,
filters.start_date, // 时间筛选参数:开始时间
filters.end_date, // 时间筛选参数:结束时间
filters.recent_days, // 时间筛选参数近N天
filters.industry_code,
filters._forceRefresh, // 强制刷新标志(用于重置按钮)
mode, // 添加 mode 到依赖
pageSize, // 添加 pageSize 到依赖
dispatch
]); // 只监听筛选参数的变化,不监听 page
// 监听模式切换 - 如果新模式数据为空,请求数据
useEffect(() => {
const isDataEmpty = currentMode === 'vertical'
? Object.keys(allCachedEventsByPage || {}).length === 0
: (allCachedEvents?.length || 0) === 0;
if (hasInitialized.current && isDataEmpty) {
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;');
// 🔧 根据 mode 直接计算 per_page避免使用可能过时的 pageSize prop
const modePageSize = mode === DISPLAY_MODES.FOUR_ROW
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;');
dispatch(fetchDynamicNews({
page: 1,
per_page: 5,
pageSize: 5, // 传递 pageSize 确保索引计算一致
clearCache: true
mode: mode,
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
pageSize: modePageSize,
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}
}, [dispatch, allCachedEvents.length]);
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
// 默认选中第一个事件
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
useEffect(() => {
if (currentPageEvents.length > 0 && !selectedEvent) {
setSelectedEvent(currentPageEvents[0]);
if (currentPageEvents.length > 0) {
// 情况1: 首次加载 - 自动选中第一个事件并触发详情加载
if (!hasAutoSelectedFirstEvent.current && !selectedEvent) {
console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;');
hasAutoSelectedFirstEvent.current = true;
setSelectedEvent(currentPageEvents[0]);
// 🎯 追踪事件点击(首次自动选中)
if (trackingFunctions.trackNewsArticleClicked) {
trackingFunctions.trackNewsArticleClicked({
eventId: currentPageEvents[0].id,
eventTitle: currentPageEvents[0].title,
importance: currentPageEvents[0].importance,
source: 'auto_select_first',
displayMode: mode,
timestamp: new Date().toISOString(),
});
}
return;
}
// 情况2: 翻页 - 如果选中的事件不在当前页,根据模式决定处理方式
const selectedEventInCurrentPage = currentPageEvents.find(
e => e.id === selectedEvent?.id
);
}
}, [currentPageEvents, selectedEvent]);
}, [currentPageEvents, selectedEvent?.id, mode, trackingFunctions]);
// 组件卸载时清理选中状态
useEffect(() => {
return () => {
setSelectedEvent(null);
};
}, []);
// 页码切换时滚动到顶部
const handlePageChangeWithScroll = useCallback((page) => {
// 先切换页码
handlePageChange(page);
// 延迟一帧确保DOM更新完成后再滚动
requestAnimationFrame(() => {
// 查找所有标记为滚动容器的元素
const containers = document.querySelectorAll('[data-scroll-container]');
containers.forEach(container => {
container.scrollTo({ top: 0, behavior: 'smooth' });
});
console.log('📜 页码切换,滚动到顶部', { containersFound: containers.length });
});
}, [handlePageChange]);
// 测量 CardHeader 高度
useEffect(() => {
const cardHeaderElement = cardHeaderRef.current;
if (!cardHeaderElement) return;
// 测量并更新高度
const updateHeaderHeight = () => {
const height = cardHeaderElement.offsetHeight;
setHeaderHeight(height);
};
// 初始测量
updateHeaderHeight();
// 监听窗口大小变化(响应式调整)
window.addEventListener('resize', updateHeaderHeight);
return () => {
window.removeEventListener('resize', updateHeaderHeight);
};
}, []);
// 监听 CardHeader 是否到达触发点,动态切换固定模式
useEffect(() => {
const cardHeaderElement = cardHeaderRef.current;
const cardBodyElement = cardBodyRef.current;
if (!cardHeaderElement || !cardBodyElement) return;
let ticking = false;
const TRIGGER_OFFSET = 150; // 提前 150px 触发(进入固定模式)
const EXIT_OFFSET = 200; // 提前 200px 退出(退出比进入更容易)
const EXIT_THRESHOLD = 30; // 接近顶部 30px 内即可退出
// 外部滚动监听:触发固定模式
const handleExternalScroll = () => {
// 只在非固定模式下监听外部滚动
if (!isFixedMode && !ticking) {
window.requestAnimationFrame(() => {
// 获取 CardHeader 相对视口的位置
const rect = cardHeaderElement.getBoundingClientRect();
const elementTop = rect.top;
// 计算触发点:总导航高度 + 150px 偏移量
const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET;
// 向上滑动:元素顶部到达触发点 → 激活固定模式
if (elementTop <= triggerPoint) {
setIsFixedMode(true);
console.log('🔒 切换为固定全屏模式', {
elementTop,
triggerPoint,
offset: TRIGGER_OFFSET
});
}
ticking = false;
});
ticking = true;
}
};
// 内部滚动监听:退出固定模式
const handleWheel = (e) => {
// 只在固定模式下监听内部滚动
if (!isFixedMode) return;
// 检测向上滚动deltaY < 0
if (e.deltaY < 0) {
window.requestAnimationFrame(() => {
// 🎯 检查 1CardHeader 位置(主要条件)
const rect = cardHeaderElement.getBoundingClientRect();
const elementTop = rect.top;
const exitPoint = TOTAL_NAV_HEIGHT + EXIT_OFFSET;
// 🎯 检查 2左侧事件列表滚动位置辅助条件
const eventListContainers = cardBodyElement.querySelectorAll('[data-event-list-container]');
const allNearTop = eventListContainers.length === 0 ||
Array.from(eventListContainers).every(
container => container.scrollTop <= EXIT_THRESHOLD
);
// 🎯 退出条件CardHeader 超过退出点 OR 左侧列表接近顶部
if (elementTop > exitPoint || allNearTop) {
setIsFixedMode(false);
console.log('🔓 恢复正常文档流模式', {
elementTop,
exitPoint,
listNearTop: allNearTop,
exitThreshold: EXIT_THRESHOLD,
reason: elementTop > exitPoint ? 'CardHeader位置' : '左侧列表滚动'
});
}
});
}
};
// 监听外部滚动
window.addEventListener('scroll', handleExternalScroll, { passive: true });
// 监听内部滚轮事件(固定模式下)
if (isFixedMode) {
cardBodyElement.addEventListener('wheel', handleWheel, { passive: true });
}
// 初次检查位置
handleExternalScroll();
return () => {
window.removeEventListener('scroll', handleExternalScroll);
cardBodyElement.removeEventListener('wheel', handleWheel);
};
}, [isFixedMode]);
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
<Card
ref={ref}
{...rest}
bg={cardBg}
borderColor={borderColor}
mb={4}
>
{/* 标题部分 */}
<CardHeader>
<CardHeader
ref={cardHeaderRef}
position={isFixedMode ? 'fixed' : 'relative'}
top={isFixedMode ? `${TOTAL_NAV_HEIGHT}px` : 'auto'}
left={isFixedMode ? 0 : 'auto'}
right={isFixedMode ? 0 : 'auto'}
maxW={isFixedMode ? '1600px' : '100%'}
mx={isFixedMode ? 'auto' : 0}
px={isFixedMode ? { base: 3, md: 4 } : undefined}
zIndex={isFixedMode ? 999 : 1}
bg={cardBg}
>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
@@ -372,9 +535,66 @@ const DynamicNewsCard = forwardRef(({
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
<VStack align="end" spacing={2}>
{/* 通知开关 */}
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启,新事件将实时推送'
: '开启后可接收实时事件推送通知'}
placement="left"
hasArrow
>
<HStack
spacing={2}
px={3}
py={2}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
>
<Icon
as={BellIcon}
boxSize={4}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
>
{browserPermission === 'granted' ? '通知已开启' : '开启通知'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
/>
</HStack>
</Tooltip>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</VStack>
</Flex>
{/* 搜索和筛选组件 */}
@@ -384,54 +604,111 @@ const DynamicNewsCard = forwardRef(({
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
/>
</Box>
</CardHeader>
{/* 主体内容 */}
<CardBody position="relative" pt={0}>
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
{currentPageEvents && currentPageEvents.length > 0 ? (
<CardBody
ref={cardBodyRef}
position={isFixedMode ? 'fixed' : 'relative'}
top={isFixedMode ? `${TOTAL_NAV_HEIGHT + headerHeight}px` : 'auto'}
left={isFixedMode ? 0 : 'auto'}
right={isFixedMode ? 0 : 'auto'}
bottom={isFixedMode ? `${FOOTER_HEIGHT}px` : 'auto'}
maxW={isFixedMode ? '1600px' : '100%'}
mx={isFixedMode ? 'auto' : 0}
h={isFixedMode ? `calc(100vh - ${TOTAL_NAV_HEIGHT + headerHeight + FOOTER_HEIGHT}px)` : 'auto'}
px={isFixedMode ? { base: 3, md: 4 } : undefined}
pt={4}
display="flex"
flexDirection="column"
overflow="hidden"
zIndex={isFixedMode ? 1000 : 1}
bg={cardBg}
>
{/* 顶部控制栏:模式切换按钮 + 筛选按钮 + 分页控制器(固定不滚动) */}
<Flex justify="space-between" align="center" mb={2} flexShrink={0}>
{/* 左侧:模式切换按钮 + 筛选按钮 */}
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}
{loading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg={useColorModeValue('rgba(255, 255, 255, 0.85)', 'rgba(26, 32, 44, 0.85)')}
display="flex"
alignItems="center"
justifyContent="center"
zIndex={10}
borderRadius="md"
>
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color={useColorModeValue('gray.600', 'gray.300')} fontWeight="medium">
正在加载最新事件...
</Text>
</VStack>
</Box>
)}
{/* 列表内容 - 始终渲染 */}
<EventScrollList
events={currentPageEvents}
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
selectedEvent={selectedEvent}
onEventSelect={setSelectedEvent}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onPageChange={handlePageChangeWithScroll}
loading={loadingPage !== null}
loadingPage={loadingPage}
error={error}
mode={mode}
onModeChange={handleModeToggle}
eventFollowStatus={eventFollowStatus}
onToggleFollow={handleToggleFollow}
hasMore={hasMore}
/>
) : !loading ? (
/* Empty 状态 - 只在非加载且无数据时显示 */
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
) : (
/* 首次加载状态 */
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
)}
{/* 详情面板 - 始终显示(如果有选中事件) */}
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
<Box mt={6}>
<DynamicNewsDetailPanel event={selectedEvent} />
</Box>
)}
</Box>
</CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxW="1600px" mx="auto" my={8}>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
)}
</Card>
);
});

View File

@@ -0,0 +1,72 @@
// src/views/Community/components/DynamicNewsCard/EventDetailScrollPanel.js
// 事件详情滚动面板组件
import React from 'react';
import { Box, Center, VStack, Text } from '@chakra-ui/react';
import DynamicNewsDetailPanel from '../DynamicNewsDetail';
/**
* 事件详情滚动面板
* 带自定义滚动条样式的事件详情容器
*
* @param {Object} selectedEvent - 当前选中的事件
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
* @param {string} detailMode - 详情模式:'full' | 'no-header'(默认 'full'
* @param {boolean} showHeader - 是否显示头部(可选,优先级高于 detailMode
*/
const EventDetailScrollPanel = ({
selectedEvent,
scrollbarTrackBg,
scrollbarThumbBg,
scrollbarThumbHoverBg,
detailMode = 'full',
showHeader,
}) => {
// 计算是否显示头部showHeader 显式指定时优先,否则根据 detailMode 判断
const shouldShowHeader = showHeader !== undefined
? showHeader
: detailMode === 'full';
return (
<Box
pl={2}
position="relative"
data-detail-panel-container="true"
sx={{
height: '100%',
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
'&::-webkit-scrollbar': {
width: '3px',
},
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
}}
>
{selectedEvent ? (
<DynamicNewsDetailPanel event={selectedEvent} showHeader={shouldShowHeader} />
) : (
<Center h="100%" minH="400px">
<VStack spacing={4}>
<Text fontSize="lg" color="gray.500">
请选择左侧事件查看详情
</Text>
</VStack>
</Center>
)}
</Box>
);
};
export default EventDetailScrollPanel;

View File

@@ -1,28 +1,21 @@
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
// 横向滚动事件列表组件
import React, { useRef } from 'react';
import React, { useRef, useCallback } from 'react';
import {
Box,
Flex,
Grid,
IconButton,
Button,
ButtonGroup,
Center,
VStack,
HStack,
Spinner,
Text,
useColorModeValue
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
import PaginationControl from './PaginationControl';
import VirtualizedFourRowGrid from './VirtualizedFourRowGrid';
import VerticalModeLayout from './VerticalModeLayout';
/**
* 事件列表组件 - 支持两种展示模式
* 事件列表组件 - 支持纵向和平铺两种展示模式
* @param {Array} events - 当前页的事件列表(服务端已分页)
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
* @param {Function} loadNextPage - 加载下一页(无限滚动)
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
* @param {Object} selectedEvent - 当前选中的事件
* @param {Function} onEventSelect - 事件选择回调
* @param {string} borderColor - 边框颜色
@@ -30,15 +23,18 @@ import PaginationControl from './PaginationControl';
* @param {number} totalPages - 总页数(由服务端返回)
* @param {Function} onPageChange - 页码改变回调
* @param {boolean} loading - 全局加载状态
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页..."
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
* @param {Function} onModeChange - 模式切换回调
* @param {Object} error - 错误状态
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
* @param {boolean} hasMore - 是否还有更多数据
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
* @param {Function} onToggleFollow - 关注按钮回调
*/
const EventScrollList = ({
events,
displayEvents,
loadNextPage,
loadPrevPage,
onFourRowEventClick,
selectedEvent,
onEventSelect,
borderColor,
@@ -46,8 +42,8 @@ const EventScrollList = ({
totalPages,
onPageChange,
loading = false,
mode = 'carousel',
onModeChange,
error,
mode = 'vertical',
hasMore = true,
eventFollowStatus = {},
onToggleFollow
@@ -59,19 +55,11 @@ const EventScrollList = ({
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
// 翻页按钮颜色
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
// 滚动条颜色
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
// 加载遮罩颜色
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
const getTimelineBoxStyle = () => {
return {
bg: timelineBg,
@@ -82,215 +70,75 @@ const EventScrollList = ({
};
};
// 重试函数
const handleRetry = useCallback(() => {
if (onPageChange) {
onPageChange(currentPage);
}
}, [onPageChange, currentPage]);
{/* 事件卡片容器 */}
return (
<Box>
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
<Flex justify="space-between" align="center" mb={2}>
{/* 模式切换按钮 */}
<ButtonGroup size="sm" isAttached>
<Button
onClick={() => onModeChange('carousel')}
colorScheme="blue"
variant={mode === 'carousel' ? 'solid' : 'outline'}
>
单排
</Button>
<Button
onClick={() => onModeChange('grid')}
colorScheme="blue"
variant={mode === 'grid' ? 'solid' : 'outline'}
>
双排
</Button>
</ButtonGroup>
<Box
ref={scrollContainerRef}
overflowX="hidden"
h="100%"
pt={0}
pb={4}
px={mode === 'four-row' ? 0 : 2}
position="relative"
data-scroll-container="true"
css={{
// 统一滚动条样式(支持横向和纵向)
'&::-webkit-scrollbar': {
width: '1px',
height: '1px',
},
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
}}
>
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
<VirtualizedFourRowGrid
display={mode === 'four-row' ? 'block' : 'none'}
columnsPerRow={4} // 每行显示4列
events={displayEvents || events} // 使用累积列表(如果有)
selectedEvent={selectedEvent}
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
hasMore={hasMore} // 是否还有更多数据
loading={loading} // 加载状态
error={error} // 错误状态
onRetry={handleRetry} // 重试回调
/>
{/* 分页控制器 */}
{totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
)}
</Flex>
{/* 横向滚动区域 */}
<Box position="relative">
{/* 左侧翻页按钮 - 上一页 */}
{currentPage > 1 && (
<IconButton
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
position="absolute"
left="0"
top="50%"
transform="translateY(-50%)"
zIndex={2}
onClick={() => onPageChange(currentPage - 1)}
variant="ghost"
size="md"
w="40px"
h="40px"
minW="40px"
borderRadius="full"
bg={arrowBtnBg}
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
_hover={{
bg: arrowBtnHoverBg,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-50%) scale(1.05)'
}}
aria-label="上一页"
title="上一页"
/>
)}
{/* 右侧翻页按钮 - 下一页 */}
{currentPage < totalPages && hasMore && (
<IconButton
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
position="absolute"
right="0"
top="50%"
transform="translateY(-50%)"
zIndex={2}
onClick={() => onPageChange(currentPage + 1)}
variant="ghost"
size="md"
w="40px"
h="40px"
minW="40px"
borderRadius="full"
bg={arrowBtnBg}
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
_hover={{
bg: arrowBtnHoverBg,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-50%) scale(1.05)'
}}
isDisabled={currentPage >= totalPages && !hasMore}
aria-label="下一页"
title="下一页"
/>
)}
{/* 事件卡片容器 */}
<Box
ref={scrollContainerRef}
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
overflowY="hidden"
pt={0}
pb={4}
px={2}
position="relative"
css={mode === 'carousel' ? {
'&::-webkit-scrollbar': {
height: '8px',
},
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
} : {}}
>
{/* 加载遮罩 */}
{loading && (
<Center
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg={loadingOverlayBg}
backdropFilter="blur(2px)"
zIndex={10}
borderRadius="md"
>
<VStack>
<Spinner size="lg" color="blue.500" thickness="3px" />
<Text fontSize="sm" color={loadingTextColor}>
加载中...
</Text>
</VStack>
</Center>
)}
{/* 模式1: 单排轮播模式 */}
{mode === 'carousel' && (
<Flex gap={4}>
{events.map((event, index) => (
<Box
key={event.id}
minW="calc((100% - 64px) / 5)"
maxW="calc((100% - 64px) / 5)"
flexShrink={0}
>
<DynamicNewsEventCard
event={event}
index={index}
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
isSelected={selectedEvent?.id === event.id}
onEventClick={(clickedEvent) => {
onEventSelect(clickedEvent);
}}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEventSelect(event);
}}
onToggleFollow={() => onToggleFollow?.(event.id)}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</Flex>
)}
{/* 模式2: 双排网格模式 */}
{mode === 'grid' && (
<Grid
templateRows="repeat(2, 1fr)"
templateColumns="repeat(5, 1fr)"
gap={4}
autoFlow="column"
>
{events.map((event, index) => (
<Box key={event.id}>
<DynamicNewsEventCard
event={event}
index={index}
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
isSelected={selectedEvent?.id === event.id}
onEventClick={(clickedEvent) => {
onEventSelect(clickedEvent);
}}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEventSelect(event);
}}
onToggleFollow={() => onToggleFollow?.(event.id)}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</Grid>
)}
</Box>
</Box>
{/* 纵向分栏模式 */}
<VerticalModeLayout
display={mode === 'vertical' ? 'flex' : 'none'}
events={events}
selectedEvent={selectedEvent}
onEventSelect={onEventSelect}
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
/>
</Box>
);
};

View File

@@ -0,0 +1,33 @@
// src/views/Community/components/DynamicNewsCard/ModeToggleButtons.js
// 事件列表模式切换按钮组
import React from 'react';
import { Button, ButtonGroup } from '@chakra-ui/react';
/**
* 事件列表模式切换按钮组
* @param {string} mode - 当前模式 'vertical' | 'four-row'
* @param {Function} onModeChange - 模式切换回调
*/
const ModeToggleButtons = ({ mode, onModeChange }) => {
return (
<ButtonGroup size="sm" isAttached>
<Button
onClick={() => onModeChange('vertical')}
colorScheme="blue"
variant={mode === 'vertical' ? 'solid' : 'outline'}
>
纵向
</Button>
<Button
onClick={() => onModeChange('four-row')}
colorScheme="blue"
variant={mode === 'four-row' ? 'solid' : 'outline'}
>
平铺
</Button>
</ButtonGroup>
);
};
export default ModeToggleButtons;

View File

@@ -0,0 +1,83 @@
// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js
// 翻页导航按钮组件
import React from 'react';
import { IconButton, useColorModeValue } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
/**
* 翻页导航按钮组件
* @param {Object} props
* @param {'prev'|'next'} props.direction - 按钮方向prev=上一页next=下一页)
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {Function} props.onPageChange - 翻页回调
* @param {string} props.mode - 显示模式只在carousel/grid模式下显示
*/
const PageNavigationButton = ({
direction,
currentPage,
totalPages,
onPageChange,
mode
}) => {
// 主题适配
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
// 根据方向计算配置
const isPrev = direction === 'prev';
const isNext = direction === 'next';
const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon;
const position = isPrev ? 'left' : 'right';
const label = isPrev ? '上一页' : '下一页';
const targetPage = isPrev ? currentPage - 1 : currentPage + 1;
const shouldShow = isPrev
? currentPage > 1
: currentPage < totalPages;
const isDisabled = isNext ? currentPage >= totalPages : false;
// 判断是否显示(只在单排/双排模式显示)
const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid');
if (!shouldRender) return null;
const handleClick = () => {
console.log(
`%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`,
'color: #3B82F6; font-weight: bold;'
);
onPageChange(targetPage);
};
return (
<IconButton
icon={<Icon boxSize={6} color="blue.500" />}
position="absolute"
{...{ [position]: 0 }}
top="50%"
transform="translateY(-50%)"
zIndex={2}
onClick={handleClick}
variant="ghost"
size="md"
w="40px"
h="40px"
minW="40px"
borderRadius="full"
bg={arrowBtnBg}
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
_hover={{
bg: arrowBtnHoverBg,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-50%) scale(1.05)'
}}
isDisabled={isDisabled}
aria-label={label}
title={label}
/>
);
};
export default PageNavigationButton;

View File

@@ -18,12 +18,12 @@ import {
} from '@chakra-ui/icons';
/**
* 分页控制器组件
* 分页控制器组件(使用 React.memo 优化,避免不必要的重新渲染)
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {Function} onPageChange - 页码改变回调
*/
const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange }) => {
const [jumpPage, setJumpPage] = useState('');
const toast = useToast();
@@ -206,6 +206,10 @@ const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
</HStack>
</Box>
);
};
}, (prevProps, nextProps) => {
// 自定义比较函数:只有当 currentPage 或 totalPages 变化时才重新渲染
return prevProps.currentPage === nextProps.currentPage &&
prevProps.totalPages === nextProps.totalPages;
});
export default PaginationControl;

View File

@@ -0,0 +1,173 @@
// src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
// 纵向分栏模式布局组件
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Tooltip, VStack, Flex, Center, Text } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon, InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel';
/**
* 纵向分栏模式布局
* 支持两种展示模式:
* - detail默认左侧事件列表 1fr | 右侧详情 2fr
* - list左侧事件列表 7fr | 右侧详情 300px
*
* @param {string} display - CSS display 属性(用于显示/隐藏组件)
* @param {Array} events - 当前页的事件列表(分页数据)
* @param {Object} selectedEvent - 当前选中的事件
* @param {Function} onEventSelect - 事件选择回调
* @param {Object} eventFollowStatus - 事件关注状态
* @param {Function} onToggleFollow - 关注按钮回调
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
* @param {string} borderColor - 边框颜色
*/
const VerticalModeLayout = ({
display = 'flex',
events,
selectedEvent,
onEventSelect,
eventFollowStatus,
onToggleFollow,
getTimelineBoxStyle,
borderColor,
}) => {
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
const [layoutMode, setLayoutMode] = useState('detail');
// 详情面板重置 key切换到 list 模式时改变,强制重新渲染)
const [detailPanelKey, setDetailPanelKey] = useState(0);
// 监听事件选择 - 自动切换到详情模式
useEffect(() => {
if (selectedEvent) {
setLayoutMode('detail');
}
}, [selectedEvent]);
// 切换布局模式
const toggleLayoutMode = () => {
const newMode = layoutMode === 'detail' ? 'list' : 'detail';
setLayoutMode(newMode);
// 如果切换到 list 模式,重置详情面板(收起所有 CollapsibleSection
if (newMode === 'list') {
setDetailPanelKey(prev => prev + 1); // 改变 key强制重新渲染
}
};
// 根据模式计算 flex 比例
const leftFlex = layoutMode === 'detail' ? '4' : '6';
const rightFlex = layoutMode === 'detail' ? '6' : '4';
return (
<Flex
display={display}
gap={6}
position="relative"
transition="all 0.3s ease-in-out"
h="100%"
overflow="hidden"
>
{/* 左侧:事件列表 - 独立滚动 */}
<Box
flex={leftFlex}
minWidth={0}
overflowY="auto"
h="100%"
data-event-list-container="true"
css={{
overscrollBehavior: 'contain',
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
},
'&::-webkit-scrollbar-thumb': {
background: '#888',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#555',
},
}}
>
{/* 事件列表 */}
{events && events.length > 0 ? (
<VStack
spacing={2}
align="stretch"
p={2}
>
{events.map((event) => (
<HorizontalDynamicNewsEventCard
key={event.id}
event={event}
isSelected={selectedEvent?.id === event.id}
onEventClick={() => onEventSelect(event)}
isFollowing={eventFollowStatus[event.id]?.isFollowing}
followerCount={eventFollowStatus[event.id]?.followerCount}
onToggleFollow={onToggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
layout="vertical"
/>
))}
</VStack>
) : (
/* 空状态 */
<Center h="100%" minH="400px">
<VStack spacing={4}>
<InfoIcon w={12} h={12} color="gray.400" />
<Text fontSize="lg" color="gray.500" textAlign="center">
当前筛选条件下暂无数据
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
请尝试调整筛选条件
</Text>
</VStack>
</Center>
)}
</Box>
{/* 右侧:事件详情 - 独立滚动 */}
<Box
flex={rightFlex}
minHeight={0}
position="relative"
overflow="hidden"
h="100%"
>
{/* 布局切换按钮 */}
<Tooltip
label={layoutMode === 'detail' ? '展开事件列表' : '展开详情面板'}
placement="left"
>
<IconButton
position="absolute"
top={2}
right={2}
zIndex={9999}
size="md"
icon={layoutMode === 'detail' ? <ViewOffIcon /> : <ViewIcon />}
onClick={toggleLayoutMode}
aria-label="切换布局模式"
colorScheme="blue"
variant="solid"
/>
</Tooltip>
{/* 详情面板 */}
<EventDetailScrollPanel
key={detailPanelKey}
detailMode="no-header"
selectedEvent={selectedEvent}
/>
</Box>
</Flex>
);
};
export default VerticalModeLayout;

View File

@@ -0,0 +1,353 @@
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
import React, { useRef, useMemo, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Box, Grid, Spinner, Text, VStack, Center, HStack, IconButton } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import { useColorModeValue } from '@chakra-ui/react';
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
/**
* 虚拟化网格组件(支持多列布局 + 无限滚动)
* @param {Object} props
* @param {string} props.display - CSS display 属性(用于显示/隐藏组件)
* @param {Array} props.events - 事件列表(累积显示)
* @param {number} props.columnsPerRow - 每行列数(默认 4单列模式传 1
* @param {React.Component} props.CardComponent - 卡片组件(默认 DynamicNewsEventCard
* @param {Object} props.selectedEvent - 当前选中的事件
* @param {Function} props.onEventSelect - 事件选择回调
* @param {Object} props.eventFollowStatus - 事件关注状态
* @param {Function} props.onToggleFollow - 关注切换回调
* @param {Function} props.getTimelineBoxStyle - 时间轴样式获取函数
* @param {string} props.borderColor - 边框颜色
* @param {Function} props.loadNextPage - 加载下一页(无限滚动)
* @param {boolean} props.hasMore - 是否还有更多数据
* @param {boolean} props.loading - 加载状态
*/
const VirtualizedFourRowGrid = ({
display = 'block',
events,
columnsPerRow = 4,
CardComponent = DynamicNewsEventCard,
selectedEvent,
onEventSelect,
eventFollowStatus,
onToggleFollow,
getTimelineBoxStyle,
borderColor,
loadNextPage,
onRefreshFirstPage, // 修改:顶部刷新回调(替代 loadPrevPage
hasMore,
loading,
error, // 新增:错误状态
onRetry, // 新增:重试回调
}) => {
const parentRef = useRef(null);
const isLoadingMore = useRef(false); // 防止重复加载
const lastRefreshTime = useRef(0); // 记录上次刷新时间用于30秒防抖
// 滚动条颜色(主题适配)
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
// 将事件按 columnsPerRow 个一组分成行
const rows = useMemo(() => {
const r = [];
for (let i = 0; i < events.length; i += columnsPerRow) {
r.push(events.slice(i, i + columnsPerRow));
}
return r;
}, [events, columnsPerRow]);
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
overscan: 2, // 预加载2行上下各1行
});
/**
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
*
* 工作原理:
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
* - 调用 usePagination.loadNextPage()
* - 内部执行 handlePageChange(currentPage + 1)
* - dispatch(fetchDynamicNews({ page: nextPage }))
* - 后端返回下一页数据30条
* - Redux 去重后追加到 fourRowEvents 数组
* - events prop 更新,虚拟滚动自动渲染新内容
*
* 2. 向上滚动到顶部 10% 以内时,触发 onRefreshFirstPage()
* - 清空缓存 + 重新加载第一页(获取最新数据)
* - 30秒防抖避免频繁刷新
* - 与5分钟定时刷新协同工作
*
* 设计要点:
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
* - 防抖机制isLoadingMore.current 防止重复触发
* - 两层缓存:
* - Redux 缓存HTTP层fourRowEvents 数组存储已加载数据,避免重复请求
* - 虚拟滚动缓存(渲染层):@tanstack/react-virtual 只渲染可见行,复用 DOM 节点
*/
useEffect(() => {
// 如果组件被隐藏,不执行滚动监听
if (display === 'none') return;
const scrollElement = parentRef.current;
if (!scrollElement) return;
const handleScroll = async () => {
// 防止重复触发
if (isLoadingMore.current || loading) return;
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
isLoadingMore.current = true;
await loadNextPage();
isLoadingMore.current = false;
}
// 向上滚动到顶部触发刷新30秒防抖
if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) {
const now = Date.now();
const timeSinceLastRefresh = now - lastRefreshTime.current;
// 30秒防抖避免频繁刷新
if (timeSinceLastRefresh >= 30000) {
console.log('%c🔄 [顶部刷新] 滚动到顶部,清空缓存并重新加载第一页', 'color: #10B981; font-weight: bold;', {
timeSinceLastRefresh: `${(timeSinceLastRefresh / 1000).toFixed(1)}`
});
isLoadingMore.current = true;
lastRefreshTime.current = now;
await onRefreshFirstPage();
isLoadingMore.current = false;
} else {
const remainingTime = Math.ceil((30000 - timeSinceLastRefresh) / 1000);
console.log('%c🔄 [顶部刷新] 防抖中,请等待', 'color: #EAB308; font-weight: bold;', {
remainingTime: `${remainingTime}`
});
}
}
};
scrollElement.addEventListener('scroll', handleScroll);
return () => scrollElement.removeEventListener('scroll', handleScroll);
}, [display, loadNextPage, onRefreshFirstPage, hasMore, loading]);
/**
* 【核心逻辑2】主动检测内容高度 - 确保内容始终填满容器
*
* 场景:
* - 初次加载时,如果 30 条数据不足以填满 800px 容器(例如显示器很大)
* - 用户无法滚动,也就无法触发上面的滚动监听逻辑
*
* 解决方案:
* - 定时检查 scrollHeight 是否小于等于 clientHeight
* - 如果内容不足,主动调用 loadNextPage() 加载更多数据
* - 递归触发,直到内容高度超过容器高度(出现滚动条)
*
* 优化:
* - 500ms 延迟:确保虚拟滚动已完成首次渲染和高度测量
* - 监听 events.length 变化:新数据加载后重新检查
*/
useEffect(() => {
// 如果组件被隐藏,不执行高度检测
if (display === 'none') return;
const scrollElement = parentRef.current;
if (!scrollElement || !loadNextPage) return;
// 延迟检查,确保虚拟滚动已渲染
const timer = setTimeout(() => {
// 防止重复触发
if (isLoadingMore.current || !hasMore || loading) return;
const { scrollHeight, clientHeight } = scrollElement;
// 如果内容高度不足以填满容器(没有滚动条),主动加载下一页
if (scrollHeight <= clientHeight) {
console.log('%c📜 [无限滚动] 内容不足以填满容器,主动加载下一页', 'color: #8B5CF6; font-weight: bold;', {
scrollHeight,
clientHeight,
eventsCount: events.length
});
isLoadingMore.current = true;
loadNextPage().finally(() => {
isLoadingMore.current = false;
});
}
}, 500);
return () => clearTimeout(timer);
}, [display, events.length, hasMore, loading, loadNextPage]);
// 错误指示器(同行显示)
const renderErrorIndicator = () => {
if (!error) return null;
return (
<Center py={6}>
<HStack spacing={2}>
<Text color="gray.500" fontSize="sm">
数据加载失败
</Text>
<IconButton
icon={<RepeatIcon />}
size="sm"
colorScheme="blue"
variant="ghost"
onClick={onRetry}
aria-label="刷新"
/>
<Text
color="blue.500"
fontSize="sm"
fontWeight="medium"
cursor="pointer"
onClick={onRetry}
_hover={{ textDecoration: 'underline' }}
>
刷新
</Text>
</HStack>
</Center>
);
};
// 底部加载指示器
const renderLoadingIndicator = () => {
if (!hasMore) {
return (
<Center py={6}>
<Text color="gray.500" fontSize="sm">
已加载全部内容
</Text>
</Center>
);
}
if (loading) {
return (
<Center py={6}>
<VStack spacing={2}>
<Spinner size="md" color="blue.500" thickness="3px" />
<Text color="gray.500" fontSize="sm">
加载中...
</Text>
</VStack>
</Center>
);
}
return null;
};
return (
<Box
ref={parentRef}
display={display}
overflowY="auto"
overflowX="hidden"
minH="800px"
maxH="800px"
w="100%"
position="relative"
css={{
// 滚动条样式
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
}}
>
{/* 虚拟滚动容器 + 底部加载指示器 */}
<Box position="relative" w="100%">
{/* 虚拟滚动内容 */}
<Box
position="relative"
w="100%"
h={`${rowVirtualizer.getTotalSize()}px`}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowEvents = rows[virtualRow.index];
return (
<Box
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
position="absolute"
top={0}
left={0}
w="100%"
transform={`translateY(${virtualRow.start}px)`}
>
{/* 使用 Grid 横向排列卡片(列数由 columnsPerRow 决定) */}
<Grid
templateColumns={`repeat(${columnsPerRow}, 1fr)`}
gap={columnsPerRow === 1 ? 3 : 4}
w="100%"
>
{rowEvents.map((event, colIndex) => (
<Box key={event.id} w="100%" minW={0}>
<CardComponent
event={event}
index={virtualRow.index * columnsPerRow + colIndex}
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
isSelected={selectedEvent?.id === event.id}
onEventClick={(clickedEvent) => {
onEventSelect(clickedEvent);
}}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEventSelect(event);
}}
onToggleFollow={() => onToggleFollow?.(event.id)}
timelineStyle={getTimelineBoxStyle?.()}
borderColor={borderColor}
/>
</Box>
))}
</Grid>
</Box>
);
})}
</Box>
{/* 底部加载指示器 - 绝对定位在虚拟内容底部 */}
<Box
position="absolute"
top={`${rowVirtualizer.getTotalSize()}px`}
left={0}
right={0}
w="100%"
>
{error ? renderErrorIndicator() : renderLoadingIndicator()}
</Box>
</Box>
</Box>
);
};
export default VirtualizedFourRowGrid;

View File

@@ -0,0 +1,40 @@
// src/views/Community/components/DynamicNewsCard/constants.js
// 动态新闻卡片组件 - 常量配置
// ========== 分页配置常量 ==========
/**
* 分页大小计算依据:
*
* 【四排模式 (FOUR_ROW_PAGE_SIZE)】
* - 容器高度: 800px (VirtualizedFourRowGrid)
* - 单行高度: ~250px (包含卡片 + 间距)
* - 每行显示: 4 列
* - 可视区域: 800px / 250px ≈ 3.2 行
* - overscan 缓冲: 2 行 (上下各预渲染1行)
* - 实际渲染区域: 3.2 + 2 = 5.2 行
* - 单次加载数据量: 7.5 行 × 4 列 = 30 个
* - 设计目标: 提供充足的缓冲数据,确保快速滚动时不出现空白
*
* 【纵向模式 (VERTICAL_PAGE_SIZE)】
* - 每页显示 10 条数据
* - 使用传统分页器,用户手动翻页
*/
export const PAGINATION_CONFIG = {
FOUR_ROW_PAGE_SIZE: 30, // 平铺模式每页数量 (7.5行 × 4列包含缓冲)
VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 (传统分页)
INITIAL_PAGE: 1, // 初始页码
};
// ========== 显示模式常量 ==========
export const DISPLAY_MODES = {
FOUR_ROW: 'four-row', // 平铺网格模式
VERTICAL: 'vertical', // 纵向分栏模式
};
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;
// ========== Toast 提示配置 ==========
export const TOAST_CONFIG = {
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
};

View File

@@ -0,0 +1,88 @@
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
// 无限滚动 Hook
import { useEffect, useRef, useCallback } from 'react';
/**
* 无限滚动 Hook
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
*
* @param {Object} options - 配置选项
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise
* @param {boolean} options.hasMore - 是否还有更多数据
* @param {boolean} options.isLoading - 是否正在加载
* @param {number} options.threshold - 触发阈值距离底部多少像素时触发默认200px
* @returns {Object} { containerRef } - 容器引用
*/
export const useInfiniteScroll = ({
onLoadMore,
hasMore = true,
isLoading = false,
threshold = 200
}) => {
const containerRef = useRef(null);
const isLoadingRef = useRef(false);
// 滚动处理函数
const handleScroll = useCallback(() => {
const container = containerRef.current;
// 检查条件:容器存在、未加载中、还有更多数据
if (!container || isLoadingRef.current || !hasMore) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
// 距离底部小于阈值时触发加载
if (distanceToBottom < threshold) {
console.log(
'%c⬇ [懒加载] 触发加载下一页',
'color: #8B5CF6; font-weight: bold;',
{
scrollTop,
scrollHeight,
clientHeight,
distanceToBottom,
threshold
}
);
isLoadingRef.current = true;
// 调用加载函数并更新状态
onLoadMore()
.then(() => {
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
})
.catch((error) => {
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
})
.finally(() => {
isLoadingRef.current = false;
});
}
}, [onLoadMore, hasMore, threshold]);
// 绑定滚动事件
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 添加滚动监听
container.addEventListener('scroll', handleScroll, { passive: true });
// 清理函数
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
// 更新 loading 状态的 ref
useEffect(() => {
isLoadingRef.current = isLoading;
}, [isLoading]);
return { containerRef };
};

View File

@@ -0,0 +1,304 @@
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
// 分页逻辑自定义 Hook
import { useState, useMemo, useCallback, useRef } from 'react';
import { fetchDynamicNews, updatePaginationPage } from '../../../../../store/slices/communityDataSlice';
import { logger } from '../../../../../utils/logger';
import {
PAGINATION_CONFIG,
DISPLAY_MODES,
DEFAULT_MODE,
TOAST_CONFIG
} from '../constants';
/**
* 分页逻辑自定义 Hook
* @param {Object} options - Hook 配置选项
* @param {Object} options.allCachedEventsByPage - 纵向模式页码映射 { 1: [...], 2: [...] }
* @param {Array} options.allCachedEvents - 平铺模式数组 [...]
* @param {Object} options.pagination - 分页元数据 { total, total_pages, current_page, per_page, page }
* @param {number} options.total - 【废弃】服务端总数量(向后兼容,建议使用 pagination.total
* @param {number} options.cachedCount - 已缓存数量
* @param {Function} options.dispatch - Redux dispatch 函数
* @param {Function} options.toast - Toast 通知函数
* @param {Object} options.filters - 筛选条件
* @param {string} options.initialMode - 初始显示模式(可选)
* @returns {Object} 分页状态和方法
*/
export const usePagination = ({
allCachedEventsByPage, // 纵向模式:页码映射
allCachedEvents, // 平铺模式:数组
pagination, // 分页元数据对象
total, // 向后兼容
cachedCount,
dispatch,
toast,
filters = {},
initialMode // 初始显示模式
}) => {
// 本地状态
const [loadingPage, setLoadingPage] = useState(null);
const [mode, setMode] = useState(initialMode || DEFAULT_MODE);
// 【核心改动】从 Redux pagination 派生 currentPage不再使用本地状态
const currentPage = pagination?.current_page || PAGINATION_CONFIG.INITIAL_PAGE;
// 使用 ref 存储最新的 filters避免 useCallback 闭包问题
// 当 filters 对象引用不变但内容改变时,闭包中的 filters 是旧值
const filtersRef = useRef(filters);
filtersRef.current = filters;
// 根据模式决定每页显示数量
const pageSize = (() => {
switch (mode) {
case DISPLAY_MODES.FOUR_ROW:
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
case DISPLAY_MODES.VERTICAL:
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
default:
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
}
})();
// 【优化】优先使用后端返回的 total_pages避免前端重复计算
// 向后兼容:如果没有 pagination 对象,则使用 total 计算
const totalPages = pagination?.total_pages || Math.ceil((pagination?.total || total || 0) / pageSize) || 1;
// 检查是否还有更多数据(使用页码判断,不受去重影响)
const hasMore = currentPage < totalPages;
// 从页码映射或数组获取当前页数据
const currentPageEvents = useMemo(() => {
if (mode === DISPLAY_MODES.VERTICAL) {
// 纵向模式:从页码映射获取当前页
return allCachedEventsByPage?.[currentPage] || [];
} else {
// 平铺模式:返回全部累积数据
return allCachedEvents || [];
}
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
// 当前显示的事件列表
const displayEvents = useMemo(() => {
if (mode === DISPLAY_MODES.FOUR_ROW) {
// 平铺模式:返回全部累积数据
return allCachedEvents || [];
} else {
// 纵向模式:返回当前页数据
return currentPageEvents;
}
}, [mode, allCachedEvents, currentPageEvents]);
/**
* 加载单个页面数据
* @param {number} targetPage - 目标页码
* @param {boolean} clearCache - 是否清空缓存第1页专用
* @returns {Promise<boolean>} 是否加载成功
*/
const loadPage = useCallback(async (targetPage, clearCache = false) => {
// 显示 loading 状态
setLoadingPage(targetPage);
try {
console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;');
console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}, mode=${mode}, clearCache=${clearCache}`, 'color: #16A34A;');
console.log(`%c 筛选条件:`, 'color: #16A34A;', filtersRef.current);
logger.debug('DynamicNewsCard', '开始加载页面数据', {
targetPage,
pageSize,
mode,
clearCache,
filters: filtersRef.current
});
// 🔍 调试dispatch 前
console.log(`%c🔵 [dispatch] 准备调用 fetchDynamicNews`, 'color: #3B82F6; font-weight: bold;', {
mode,
page: targetPage,
per_page: pageSize,
pageSize,
clearCache,
filters: filtersRef.current
});
const result = await dispatch(fetchDynamicNews({
mode: mode, // 传递 mode 参数
per_page: pageSize,
pageSize: pageSize,
clearCache: clearCache, // 传递 clearCache 参数
...filtersRef.current, // 从 ref 读取最新筛选条件
page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖)
})).unwrap();
// 🔍 调试dispatch 后
console.log(`%c🔵 [dispatch] fetchDynamicNews 返回结果`, 'color: #3B82F6; font-weight: bold;', result);
console.log(`%c🟢 [API请求] 第${targetPage}页加载完成`, 'color: #16A34A; font-weight: bold;');
logger.debug('DynamicNewsCard', `${targetPage} 页加载完成`);
return true;
} catch (error) {
logger.error('DynamicNewsCard', 'loadPage', error, {
targetPage
});
toast({
title: '加载失败',
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
status: 'error',
duration: TOAST_CONFIG.DURATION_ERROR,
isClosable: true,
position: 'top'
});
return false;
} finally {
setLoadingPage(null);
}
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
// 翻页处理第1页强制刷新 + 其他页缓存)
const handlePageChange = useCallback(async (newPage) => {
// 边界检查 1: 检查页码范围
if (newPage < 1 || newPage > totalPages) {
console.log(`%c⚠ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
logger.warn('usePagination', '页码超出范围', { newPage, totalPages });
return;
}
// 边界检查 2: 检查是否重复点击
if (newPage === currentPage) {
console.log(`%c⚠ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
logger.debug('usePagination', '页码未改变', { newPage });
return;
}
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
if (loadingPage === newPage) {
console.log(`%c⚠ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
logger.warn('usePagination', '竞态条件:相同页面正在加载', { loadingPage, newPage });
return;
}
// 如果正在加载其他页面,允许切换(会取消当前加载状态,开始新的加载)
if (loadingPage !== null && loadingPage !== newPage) {
console.log(`%c🔄 [翻页] 正在加载第${loadingPage}页,用户切换到第${newPage}`, 'color: #8B5CF6; font-weight: bold;');
logger.info('usePagination', '用户切换页面,继续处理新请求', { loadingPage, newPage });
// 继续执行loadPage 会覆盖 loadingPage 状态
}
console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;');
console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 模式: ${mode}`, 'color: #3B82F6;');
// 【核心逻辑】第1页特殊处理强制清空缓存并重新加载
if (newPage === 1) {
console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;');
logger.info('usePagination', '第1页强制刷新', { mode });
// clearCache = trueAPI 会更新 Redux pagination.current_page
await loadPage(newPage, true);
return;
}
// 【其他页】检查缓存
if (mode === DISPLAY_MODES.VERTICAL) {
// 纵向模式:检查页码映射中是否有缓存
const isPageCached = allCachedEventsByPage?.[newPage]?.length > 0;
console.log(`%c🟡 [缓存检查] 第${newPage}页缓存状态`, 'color: #EAB308; font-weight: bold;');
console.log(`%c 是否已缓存: ${isPageCached ? '✅ 是' : '❌ 否'}`, `color: ${isPageCached ? '#16A34A' : '#DC2626'};`);
if (isPageCached) {
console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;');
// 使用缓存数据,同步更新 Redux pagination.current_page
dispatch(updatePaginationPage({ mode, page: newPage }));
} else {
console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;');
// clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
}
} else {
// 平铺模式直接加载新页追加模式clearCache=false
console.log(`%c🟡 [平铺模式] 加载第${newPage}`, 'color: #EAB308; font-weight: bold;');
// clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
}
}, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]);
// 加载下一页(用于无限滚动)
const loadNextPage = useCallback(async () => {
// 使用 hasMore 判断(基于 currentPage < totalPages
if (!hasMore || loadingPage !== null) {
logger.debug('DynamicNewsCard', '无法加载下一页', {
currentPage,
totalPages,
hasMore,
loadingPage,
reason: !hasMore ? '已加载全部数据 (currentPage >= totalPages)' : '正在加载中'
});
return Promise.resolve(false); // 没有更多数据或正在加载
}
const nextPage = currentPage + 1;
logger.debug('DynamicNewsCard', '懒加载:加载下一页', { currentPage, nextPage, hasMore, totalPages });
try {
await handlePageChange(nextPage);
return true;
} catch (error) {
logger.error('DynamicNewsCard', '懒加载失败', error, { nextPage });
return false;
}
}, [currentPage, totalPages, hasMore, loadingPage, handlePageChange]);
// 加载上一页(用于双向无限滚动)
const loadPrevPage = useCallback(async () => {
if (currentPage <= 1 || loadingPage !== null) {
logger.debug('DynamicNewsCard', '无法加载上一页', {
currentPage,
loadingPage,
reason: currentPage <= 1 ? '已是第一页' : '正在加载中'
});
return Promise.resolve(false); // 已经是第一页或正在加载
}
const prevPage = currentPage - 1;
logger.debug('DynamicNewsCard', '懒加载:加载上一页', { currentPage, prevPage });
try {
await handlePageChange(prevPage);
return true;
} catch (error) {
logger.error('DynamicNewsCard', '懒加载上一页失败', error, { prevPage });
return false;
}
}, [currentPage, loadingPage, handlePageChange]);
// 模式切换处理(简化版 - 模式切换时始终请求数据,因为两种模式使用独立存储)
const handleModeToggle = useCallback((newMode) => {
if (newMode === mode) return;
setMode(newMode);
// currentPage 由 Redux pagination.current_page 派生,会在下次请求时自动更新
// pageSize 会根据 mode 自动重新计算第46-56行
}, [mode]);
return {
// 状态
currentPage,
mode,
loadingPage,
pageSize,
totalPages,
hasMore,
currentPageEvents,
displayEvents, // 当前显示的事件列表
// 方法
handlePageChange,
handleModeToggle,
loadNextPage, // 加载下一页(用于无限滚动)
loadPrevPage // 加载上一页(用于双向无限滚动)
};
};

View File

@@ -3,11 +3,13 @@
import React from 'react';
import {
Box,
Flex,
HStack,
Heading,
Badge,
IconButton,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
@@ -19,40 +21,97 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
* @param {Function} props.onModeToggle - 模式切换回调
* @param {boolean} props.isLocked - 是否锁定(不可展开)
*/
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
const CollapsibleHeader = ({
title,
isOpen,
onToggle,
count = null,
subscriptionBadge = null,
showModeToggle = false,
currentMode = 'detailed',
onModeToggle = null,
isLocked = false
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取按钮文案
const getButtonText = () => {
if (currentMode === 'simple') {
return '查看详情'; // 简单模式时,按钮显示"查看详情"
}
return '精简模式'; // 详细模式时,按钮显示"精简模式"
};
// 获取按钮图标
const getButtonIcon = () => {
if (currentMode === 'simple') {
return null; // 简单模式不显示图标
}
// 详细模式:展开显示向上箭头,收起显示向下箭头
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
};
return (
<Flex
justify="space-between"
align="center"
cursor="pointer"
onClick={onToggle}
cursor={showModeToggle ? 'default' : 'pointer'}
onClick={showModeToggle ? undefined : onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
_hover={showModeToggle ? {} : { bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
<Heading size="sm" color={headingColor}>
{title}
</Heading>
{count !== null && (
{subscriptionBadge && (
<Box>
{subscriptionBadge}
</Box>
)}
{count !== null && count > 0 && (
<Badge colorScheme="blue" borderRadius="full">
{count}
</Badge>
)}
</HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
{showModeToggle && onModeToggle && (
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={getButtonIcon()}
onClick={(e) => {
e.stopPropagation();
onModeToggle(e);
}}
>
{getButtonText()}
</Button>
)}
{/* showModeToggle=false 时显示原有的 IconButton */}
{!showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
)}
</Flex>
);
};

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Collapse,
@@ -16,24 +16,107 @@ import CollapsibleHeader from './CollapsibleHeader';
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.children - 子内容
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.isLocked - 是否锁定(不可展开)
* @param {Function} props.onLockedClick - 锁定时点击的回调
* @param {React.ReactNode} props.children - 详细内容
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed'
*/
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
const CollapsibleSection = ({
title,
isOpen,
onToggle,
count = null,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
children,
simpleContent = null,
showModeToggle = false,
defaultMode = 'detailed'
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
const handleToggle = () => {
if (isLocked && onLockedClick) {
onLockedClick();
} else if (!isLocked) {
onToggle();
}
};
// 处理模式切换
const handleModeToggle = (e) => {
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
if (isLocked && onLockedClick) {
// 如果被锁定,触发付费弹窗
onLockedClick();
return;
}
if (displayMode === 'detailed') {
// 从详细模式切换到精简模式
setDisplayMode('simple');
} else {
// 从精简模式切换回详细模式
setDisplayMode('detailed');
// 切换回详细模式时,如果未展开则自动展开
if (!isOpen && onToggle) {
onToggle();
}
}
};
// 渲染精简模式
const renderSimpleMode = () => {
if (!simpleContent) return null;
return (
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{simpleContent}
</Box>
);
};
// 渲染详细模式
const renderDetailedMode = () => {
return (
<Collapse
in={isOpen && !isLocked}
animateOpacity
unmountOnExit={false}
startingHeight={0}
>
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{children}
</Box>
</Collapse>
);
};
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={onToggle}
onToggle={handleToggle}
count={count}
subscriptionBadge={subscriptionBadge}
showModeToggle={showModeToggle}
currentMode={displayMode}
onModeToggle={handleModeToggle}
isLocked={isLocked}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{children}
</Box>
</Collapse>
{/* 根据当前模式渲染对应内容 */}
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
</Box>
);
};

View File

@@ -0,0 +1,100 @@
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
// 精简信息栏组件(无头部模式下右上角显示)
import React from 'react';
import {
HStack,
Badge,
Text,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 精简信息栏组件
* 在无头部模式下,显示在 CardBody 右上角
* 包含:重要性徽章、浏览次数、关注按钮
*
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const viewCountBg = useColorModeValue('white', 'gray.700');
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
return (
<HStack
position="absolute"
top={3}
right={3}
spacing={3}
zIndex={1}
>
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
<Badge
px={3}
py={1.5}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
bgGradient={
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
'linear(to-r, gray.500, gray.700)'
}
color="white"
boxShadow="lg"
display="flex"
alignItems="center"
gap={1}
>
<Icon as={importance.icon} boxSize={4} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
{/* 浏览次数 - 添加容器背景以提高可读性 */}
<HStack
spacing={1}
bg={viewCountBg}
px={2}
py={1}
borderRadius="md"
boxShadow="sm"
>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}
</Text>
</HStack>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</HStack>
);
};
export default CompactMetaBar;

View File

@@ -0,0 +1,113 @@
// src/views/Community/components/DynamicNewsDetail/CompactStockItem.js
// 精简模式股票卡片组件(浮动卡片样式)
import React from 'react';
import {
Box,
Text,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils';
/**
* 精简模式股票卡片组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* @param {Object} props.quote - 股票行情数据(可选)
*/
const CompactStockItem = ({ stock, quote = null }) => {
const nameColor = useColorModeValue('gray.700', 'gray.300');
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
};
// 格式化涨跌幅显示
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅数据(优先使用 quotefallback 到 stock
const change = quote?.change ?? stock.daily_change ?? null;
return (
<Tooltip
label={`${stock.stock_name} - 点击查看详情`}
placement="top"
hasArrow
bg="gray.700"
color="white"
fontSize="xs"
>
<Box
bgGradient={getChangeBackgroundGradient(change)}
borderWidth="3px"
borderColor={getChangeBorderColor(change)}
borderRadius="2xl"
p={4}
onClick={handleViewDetail}
cursor="pointer"
boxShadow="lg"
position="relative"
overflow="hidden"
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
bg: getChangeBorderColor(change),
}}
_hover={{
boxShadow: '2xl',
transform: 'translateY(-4px) scale(1.02)',
}}
transition="all 0.3s ease-in-out"
display="inline-block"
minW="150px"
>
{/* 股票代码 */}
<Text
fontSize="md"
fontWeight="bold"
color={getChangeColor(change)}
mb={2}
textAlign="center"
>
{stock.stock_code}
</Text>
{/* 涨跌幅 - 超大号显示 */}
<Text
fontSize="3xl"
fontWeight="black"
color={getChangeColor(change)}
textAlign="center"
lineHeight="1"
textShadow="0 1px 2px rgba(0,0,0,0.1)"
>
{formatChange(change)}
</Text>
{/* 股票名称(小字) */}
<Text
fontSize="xs"
color={nameColor}
mt={2}
textAlign="center"
noOfLines={1}
fontWeight="medium"
>
{stock.stock_name}
</Text>
</Box>
</Tooltip>
);
};
export default CompactStockItem;

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
@@ -10,6 +10,8 @@ import {
Text,
Spinner,
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
@@ -17,45 +19,114 @@ import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
/**
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true
*/
const DynamicNewsDetailPanel = ({ event }) => {
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch();
const { user } = useAuth();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
const toast = useToast();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free';
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
// 使用 Hook 获取实时数据
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count
const [fullEventDetail, setFullEventDetail] = useState(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// 权限判断函数
const hasAccess = useCallback((requiredTier) => {
const tierLevel = { free: 0, pro: 1, max: 2 };
const result = tierLevel[userTier] >= tierLevel[requiredTier];
return result;
}, [userTier]);
// 升级弹窗状态
const [upgradeModal, setUpgradeModal] = useState({
isOpen: false,
requiredLevel: 'pro',
featureName: ''
});
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
const {
stocks,
quotes,
eventDetail,
historicalEvents,
expectationScore,
loading
} = useEventStocks(event?.id, event?.created_at);
loading,
loadStocksData,
loadHistoricalData,
loadChainAnalysis
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
if (!event?.id) return;
setLoadingDetail(true);
try {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
eventId: event?.id
});
} finally {
setLoadingDetail(false);
}
}, [event?.id]);
// 相关股票、相关概念、历史事件和传导链的权限
const canAccessStocks = hasAccess('pro');
const canAccessConcepts = hasAccess('pro');
const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 初始值为 false由 useEffect 根据权限动态设置
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
// 折叠状态管理
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
@@ -67,6 +138,95 @@ const DynamicNewsDetailPanel = ({ event }) => {
}
});
// 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => {
setUpgradeModal({
isOpen: true,
requiredLevel,
featureName
});
}, []);
// 关闭升级弹窗
const handleCloseUpgradeModal = useCallback(() => {
setUpgradeModal({
isOpen: false,
requiredLevel: 'pro',
featureName: ''
});
}, []);
// 相关股票 - 展开时加载(需要 PRO 权限)
const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen;
setIsStocksOpen(newState);
if (newState && !hasLoadedStocks) {
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
}
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen);
}, [isConceptsOpen]);
// 历史事件对比 - 展开时加载
const handleHistoricalToggle = useCallback(() => {
const newState = !isHistoricalOpen;
setIsHistoricalOpen(newState);
if (newState && !hasLoadedHistorical) {
console.log('%c📜 [历史事件] 首次展开,加载历史事件数据', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData();
setHasLoadedHistorical(true);
}
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
const newState = !isTransmissionOpen;
setIsTransmissionOpen(newState);
if (newState && !hasLoadedTransmission) {
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
loadChainAnalysis();
setHasLoadedTransmission(true);
}
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
// 事件切换时重置所有子模块状态
useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
// 🎯 加载事件详情(增加浏览量)
loadEventDetail();
// PRO 和 MAX 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks;
setIsStocksOpen(shouldOpenStocks);
setHasLoadedStocks(false);
// PRO 和 MAX 会员自动加载股票数据(无论是否展开)
const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
if (shouldLoadStocks) {
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
userTier
});
loadStocksData();
setHasLoadedStocks(true);
}
setIsConceptsOpen(false);
setIsHistoricalOpen(false);
setHasLoadedHistorical(false);
setIsTransmissionOpen(false);
setHasLoadedTransmission(false);
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
if (!event?.id) return;
@@ -127,51 +287,106 @@ const DynamicNewsDetailPanel = ({ event }) => {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部信息区 */}
<EventHeaderInfo
event={event}
<CardBody position="relative">
{/* 无头部模式:显示右上角精简信息栏 */}
{!showHeader && (
<CompactMetaBar
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
<VStack align="stretch" spacing={3}>
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
{showHeader && (
<EventHeaderInfo
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
{/* 事件描述 */}
<EventDescriptionSection description={event.description} />
{/* 相关概念 */}
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
onToggle={handleStocksToggle}
count={stocks?.length || 0}
subscriptionBadge={(() => {
if (!canAccessStocks) {
return <SubscriptionBadge tier="pro" size="sm" />;
}
return null;
})()}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
defaultMode="detailed"
simpleContent={
loading.stocks || loading.quotes ? (
<Center py={2}>
<Spinner size="sm" color="blue.500" />
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
</Center>
) : (
<Wrap spacing={4}>
{stocks?.map((stock, index) => (
<WrapItem key={index}>
<CompactStockItem
stock={stock}
quote={quotes[stock.stock_code]}
/>
</WrapItem>
))}
</Wrap>
)
}
>
{loading.stocks || loading.quotes ? (
<Center py={4}>
<Spinner size="md" color="blue.500" />
<Text ml={2} color={textColor}>加载股票数据中...</Text>
</Center>
) : (
<RelatedStocksSection
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
</CollapsibleSection>
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
<RelatedConceptsSection
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={isConceptsOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
/>
{/* 相关股票(可折叠) */}
{loading.stocks || loading.quotes ? (
<Center py={4}>
<Spinner size="md" color="blue.500" />
<Text ml={2} color={textColor}>加载股票数据中...</Text>
</Center>
) : (
<RelatedStocksSection
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isOpen={isStocksOpen}
onToggle={() => setIsStocksOpen(!isStocksOpen)}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
{/* 历史事件对比(可折叠) */}
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessHistorical}
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
>
{loading.historicalEvents ? (
<Center py={4}>
@@ -181,15 +396,19 @@ const DynamicNewsDetailPanel = ({ event }) => {
) : (
<HistoricalEvents
events={historicalEvents || []}
expectationScore={expectationScore}
/>
)}
</CollapsibleSection>
{/* 传导链分析(可折叠) */}
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission}
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
>
<TransmissionChainAnalysis
eventId={event.id}
@@ -198,6 +417,17 @@ const DynamicNewsDetailPanel = ({ event }) => {
</CollapsibleSection>
</VStack>
</CardBody>
{/* 升级弹窗 */}
{upgradeModal.isOpen ? (
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}
onClose={handleCloseUpgradeModal}
requiredLevel={upgradeModal.requiredLevel}
featureName={upgradeModal.featureName}
currentLevel={userTier}
/>
): null }
</Card>
);
};

View File

@@ -8,6 +8,8 @@ import {
HStack,
Heading,
Text,
Badge,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
@@ -100,7 +102,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
</Text>
</Flex>
{/* 第三行:涨跌幅指标 + 重要性文本 */}
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
<HStack spacing={3} align="center">
<Box maxW="500px">
<StockChangeIndicators
@@ -110,19 +112,28 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
/>
</Box>
{/* 重要性文本 */}
<Box
bg={importance.bgColor}
borderWidth="2px"
borderColor={importance.badgeBg}
px={2}
py={1}
borderRadius="md"
{/* 重要性徽章 - 使用渐变色和图标 */}
<Badge
px={4}
py={2}
borderRadius="full"
fontSize="md"
fontWeight="bold"
bgGradient={
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
'linear(to-r, gray.500, gray.700)'
}
color="white"
boxShadow="lg"
display="flex"
alignItems="center"
gap={2}
>
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
重要性{getImportanceText()}
</Text>
</Box>
<Icon as={importance.icon} boxSize={5} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
</HStack>
</Box>
);

View File

@@ -161,8 +161,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
return (
<div
style={{
width: 140,
height: 40,
width: '100%',
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}

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