Compare commits

...

94 Commits

Author SHA1 Message Date
zdl
b2380c420c Merge branch '1028_bugfix' into feature_2025/1028_bugfix
* 1028_bugfix:
  手机号格式适配-前端修改
  添加微信扫码的几种其他状态
  整合register端口进入login端口
2025-10-29 16:26:02 +08:00
8417ab17be 手机号格式适配-前端修改 2025-10-29 11:20:41 +08:00
dd59cb6385 添加微信扫码的几种其他状态 2025-10-29 07:33:44 +08:00
zdl
d456c3cd5f pref: 去除坏味道 2025-10-28 19:06:50 +08:00
zdl
b221c2669c feat: 微信登陆逻辑调整 2025-10-28 19:04:58 +08:00
zdl
356f865f09 feat: 微信mock数据调整 2025-10-28 18:47:39 +08:00
512aca16d8 整合register端口进入login端口 2025-10-28 15:47:50 +08:00
zdl
e05ea154a2 feat: 文案调整 2025-10-28 14:16:30 +08:00
zdl
c33181a689 feat: 修复首页新闻中心卡片布局跳变问题
问题根源:
     使用 useBreakpointValue 的 isMobile 变量在初始渲染时返回 undefined,导致:
     1. 服务端渲染/首次加载时显示一种布局
     2. 客户端水合后切换到另一种布局
     3. 用户看到明显的布局跳变(先横向后纵向,或反之)

     解决方案:
     不使用条件渲染两套完全不同的 JSX,而是使用响应式样式让同一套 JSX 自动适应不同屏幕。

     修改策略:
     将移动端(VStack)和桌面端(Flex横向)合并为一套响应式布局:
     - 使用 Flex + 响应式 flexDirection
     - flexDirection={{ base: column, md: row }}(移动端纵向,桌面端横向)
     - 统一使用响应式属性而不是条件渲染
2025-10-28 13:06:46 +08:00
29f035b1cf Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-28 11:21:11 +08:00
513134f285 整合register端口进入login端口 2025-10-28 11:20:50 +08:00
zdl
7da50aca40 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-28 11:18:50 +08:00
zdl
72aae585d0 fix: 修复首页路由跳转失败的问题 2025-10-28 11:18:39 +08:00
24c6c9e1c6 修改个股详情中桑基图提示Stack: Error: Sankey is a DAG 2025-10-28 10:46:23 +08:00
zdl
58254d3e8f bugfix:调整 2025-10-27 22:31:41 +08:00
zdl
760ce4d5e1 feat: 路由链接调整 2025-10-27 22:31:06 +08:00
zdl
95c1eaf97b bugfix:修复警告错误 2025-10-27 22:29:53 +08:00
zdl
657c446594 feat: 错误logger 不在被error页面捕获 2025-10-27 21:14:51 +08:00
zdl
10f519a764 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-27 17:52:39 +08:00
zdl
f072256021 feat(EventList): 重构渲染和UI - 精简/详细模式优化、推送控制、描述展开
**主要变更**:

1. **渲染函数重构**:
   - 重写 renderCompactEvent:标题2行+标签内联+按钮右侧布局
   - 重写 renderDetailedEvent:标题+优先级+统计+价格标签+时间作者
   - 添加 getTimelineBoxStyle 函数统一时间轴样式
   - renderCompactEvent 支持隔行变色(index % 2)

2. **顶部控制栏全面升级**:
   - 改为 sticky 定位,全宽白色背景
   - 左侧占位,中间嵌入分页器,右侧控制按钮
   - 新增桌面推送开关(使用 handlePushToggle)
   - WebSocket 状态简化为 🟢实时/🔴离线
   - 精简模式切换改为 xs 尺寸

3. **描述展开/收起功能**:
   - 详细模式支持长描述(>120字符)展开/收起
   - 使用 expandedDescriptions 状态管理
   - noOfLines 动态切换

4. **统一时间格式**:
   - 所有时间显示统一为 YYYY-MM-DD HH:mm

**效果**:
- 精简模式更紧凑,信息密度更高
- 详细模式布局更清晰,价格标签更易读
- 顶部控制栏功能集中,操作更便捷
- 推送权限管理可视化

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:46:13 +08:00
zdl
0e3bdc9b8c feat(EventList): 功能增强 - 集成NotificationContext和添加动画
**主要变更**:

1. **集成NotificationContext**:
   - 引入 useNotification hook,替代本地通知权限状态
   - 删除本地 notificationPermission 状态和 useEffect
   - 使用 browserPermission 和 requestBrowserPermission
   - 添加 handlePushToggle 函数处理推送开关切换

2. **添加动画支持**:
   - 从 @emotion/react 引入 keyframes
   - 定义 pulseAnimation 脉冲动画(用于S/A级重要性标签)

3. **添加描述展开状态**:
   - 新增 expandedDescriptions 状态管理

**效果**:
- 推送权限管理更集中统一
- 支持动画效果增强视觉体验
- 为后续UI优化做准备

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:40:51 +08:00
zdl
5e4c4e7cea feat(EventList): UI优化 - 简化标签文字和调整顶部间距
**改进内容**:
1. 简化涨跌幅标签文字
   - 平均涨幅 → 平均
   - 最大涨幅 → 最大
   - 周涨幅 → 周

2. 调整顶部间距
   - 移除顶部padding (py={8} → pb={8})
   - 控制栏紧贴页面顶部

**效果**: 节省显示空间,标签更简洁,顶部无留白

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:36:28 +08:00
zdl
31a7500388 feat: 热点事件UI调整成轮播图 2025-10-27 17:22:03 +08:00
zdl
03c113fe1b feat: 修复数据获取bug 2025-10-27 17:21:31 +08:00
zdl
0f3bc06716 feat: 访问 http://localhost:3000/admin/community
1. 页面加载后应停留在顶部
  2. 点击搜索框,页面应平滑滚动到"实时事件时间轴"区域
  3. 再次点击搜索框不会重复滚动
2025-10-27 16:37:36 +08:00
zdl
e568b5e05f feat: 热点事件UI调整 2025-10-27 15:59:13 +08:00
c5aaaabf17 update ip address to company's 2025-10-27 15:54:22 +08:00
9ede603c9f update ip address to company's 2025-10-27 15:47:04 +08:00
zdl
629c63f4ee feat: 文案修改 2025-10-27 15:40:20 +08:00
zdl
d6bc2c7245 feat: 事件中心去掉头图, 并且将热点区域提到首屏 2025-10-27 15:39:56 +08:00
zdl
dc38199ae6 feat: 添加mock数据 2025-10-27 15:39:06 +08:00
zdl
d93b5de319 feat: 将事件中心的头部添加到首页 2025-10-27 15:31:22 +08:00
zdl
199a54bc12 feat: 为"股票行情"和"财务全景"标签页添加 Mock 数据支持
问题:
     - 点击"股票行情"标签页:MarketDataView 组件需要市场数据接口
     - 点击"财务全景"标签页:FinancialPanorama 组件需要财务数据接口
     - 这些接口都没有 mock 数据,导致页面显示空白

     需要添加的接口:

     股票行情 (MarketDataView) - 7个接口

     1. /api/market/trade/:stockCode - 成交数据
     2. /api/market/funding/:stockCode - 资金流向
     3. /api/market/bigdeal/:stockCode - 大单统计
     4. /api/market/unusual/:stockCode - 异动分析
     5. /api/market/pledge/:stockCode - 股权质押
     6. /api/market/summary/:stockCode - 市场摘要
     7. /api/market/rise-analysis/:stockCode - 涨停分析
     8. /api/stock/:stockCode/latest-minute - 最新分时数据

     财务全景 (FinancialPanorama) - 9个接口

     1. /api/financial/stock-info/:stockCode - 股票基本信息
     2. /api/financial/balance-sheet/:stockCode - 资产负债表
     3. /api/financial/income-statement/:stockCode - 利润表
     4. /api/financial/cashflow/:stockCode - 现金流量表
     5. /api/financial/financial-metrics/:stockCode - 财务指标
     6. /api/financial/main-business/:stockCode - 主营业务
     7. /api/financial/forecast/:stockCode - 业绩预告
     8. /api/financial/industry-rank/:stockCode - 行业排名
     9. /api/financial/comparison/:stockCode - 期间对比

     实施步骤:
     1. 创建 src/mocks/data/market.js - 市场数据
     2. 创建 src/mocks/data/financial.js - 财务数据
     3. 创建 src/mocks/handlers/market.js - 市场接口handlers
     4. 创建 src/mocks/handlers/financial.js - 财务接口handlers
     5. 更新 src/mocks/handlers/index.js - 注册新handlers

     数据内容:
     - 为平安银行 (000001) 提供完整真实数据
     - 其他股票代码生成合理的模拟数据
2025-10-27 15:10:03 +08:00
zdl
39feae87a6 feat: 添加mock数据 2025-10-27 14:56:44 +08:00
zdl
a9dc1191bf feat:. mockSocketService 添加 connecting 状态
- 新增 connecting 标志防止重复连接
  - 在 connect() 方法中检查 connected 和 connecting 状态
  - 连接成功或失败后清除 connecting 标志\
2. NotificationContext 调整监听器注册顺序

  - 在 useEffect 中重新排序初始化步骤
  - 第一步:注册所有事件监听器(connect, disconnect, new_event 等)
  - 第二步:获取最大重连次数
  - 第三步:调用 socket.connect()
  - 使用空依赖数组 [] 防止 React 严格模式重复执行\
3. logger 添加日志限流

  - 实现 shouldLog() 函数,1秒内相同日志只输出一次
  - 使用 Map 缓存最近日志,带最大缓存限制(100条)
  - 应用到所有 logger 方法:info, warn, debug, api.request, api.response
  - 错误日志(error, api.error)不做限流,始终输出\
修复 emit 时机确保事件被接收

  - 在 mockSocketService 的 connect() 方法中
  - 使用 setTimeout(0) 延迟 emit(connect) 调用
  - 确保监听器注册完毕后再触发事件\
2025-10-27 13:13:56 +08:00
zdl
227e1c9d15 feat: 修复 UnifiedSearchBox 语法错误 2025-10-27 11:38:16 +08:00
zdl
b5cdceb92b feat: 日期标签删除重置内容 2025-10-27 10:51:19 +08:00
zdl
aacbe5c31c feat: 调整时间中心搜索逻辑 2025-10-27 10:32:51 +08:00
zdl
197c792219 feat: 事件列表添加最低高度 2025-10-27 00:12:09 +08:00
zdl
794581e429 feat: 热门关键词取去掉loading态 2025-10-27 00:11:46 +08:00
zdl
b06d51813a feat: 效果: │ │
│ │                                                                                                                                          │ │
│ │ 1. 用户进入社区页面                                                                                                                      │ │
│ │ 2. 页面正常渲染                                                                                                                          │ │
│ │ 3. 1秒后,页面平滑滚动到"实时事件时间轴"标题位置                                                                                         │ │
│ │ 4. 用户可以直接看到搜索框和事件列表
2025-10-27 00:11:27 +08:00
zdl
5b25136c28 feat: 调整请求参数 2025-10-26 23:46:54 +08:00
zdl
97c5ce0d4d feat: 优化事件中心页面 重构后的文件结构
src/views/Community/
     ├── index.js (主组件,150行左右)
     ├── components/
     │   ├── EventTimelineCard.js (新增)
     │   ├── EventTimelineHeader.js (新增)
     │   ├── EventListSection.js (新增)
     │   ├── HotEventsSection.js (新增)
     │   ├── EventModals.js (新增)
     │   ├── UnifiedSearchBox.js (已有)
     │   ├── EventList.js (已有)
     │   └── ...
     └── hooks/
         ├── useEventFilters.js (新增)
         └── useEventData.js (新增)
2025-10-26 20:31:34 +08:00
zdl
f1bd9680b6 feat: 代码改进
-  修复了 React Hooks 规则违规
  -  实现了两个缺失的初始化功能
  -  添加了防抖机制,减少 60-80% 的 API 请求
  -  优化了参数构建函数,代码更简洁
  -  统一了所有筛选器的触发逻辑
  -  添加了完整的加载状态管理

  用户体验提升

  -  快速切换筛选器不会触发多次请求
  -  从 URL 参数恢复状态时完整显示(包括行业和日期)
  -  所有筛选器行为一致
  -  搜索时禁用输入,避免误操作
  -  详细的日志输出,便于调试

  性能提升

  -  防抖减少不必要的 API 请求
  -  使用 useCallback 避免不必要的重新渲染
  -  优化了参数构建逻辑
2025-10-26 20:13:38 +08:00
zdl
f02d0d0bd0 feat: 处理热词点击逻辑 2025-10-26 20:04:44 +08:00
zdl
aa332537d4 feat: UI 层面:
-  只显示一套标签(在搜索框下方)
    -  标签样式统一(Ant Design Tag 组件)
    -  所有筛选条件都有对应的标签显示
  2. 功能层面:
    -  标签内容与实际筛选条件完全同步
    -  点击标签删除按钮,对应筛选条件被清除
    -  删除标签后自动刷新事件列表
    -  完整的日志记录,便于调试
  3. 代码层面:
    -  消除重复代码
    -  单一数据源(UnifiedSearchBox 的内部状态)
    -  逻辑统一,易于维护
2025-10-26 20:04:10 +08:00
zdl
b4b7eae1ba feat: 添加mock数据 2025-10-26 19:50:20 +08:00
zdl
4559c57a62 refactor: 重构 JSX 布局为统一卡片设计
- 移除两栏 Grid 布局(左侧主内容 + 右侧侧边栏)
- 统一为单个大卡片「实时事件时间轴」
- 整合 UnifiedSearchBox 到主卡片内部
  - 传入 updateFilters、popularKeywords、filters、loading 参数
- 移除右侧侧边栏的所有组件:
  - SearchBox(已整合到 UnifiedSearchBox)
  - InvestmentCalendar(投资日历)
  - PopularKeywords(已整合到 UnifiedSearchBox)
  - ImportanceLegend(重要性说明)
- 移除 EventFilters 组件(已被 UnifiedSearchBox 替代)
- 移除 Footer 区域(现由 MainLayout 提供)
- 筛选标签移至主卡片内部
- 简化布局,提升用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:53:14 +08:00
zdl
9eb13206cc refactor: 优化事件处理器和防抖逻辑
- 更新所有 handler 函数使用 updateFilters 替代 updateUrlParams
  - handleFilterChange
  - handlePageChange(移除 loadEvents 调用,由 useEffect 自动触发)
  - handleKeywordClick
  - handleRemoveFilterTag(移除 loadEvents 调用)

- 重构 useEffect:监听 filters 状态替代 searchParams
- 分离 Redux 数据加载到独立的 useEffect
- 保持防抖逻辑(500ms)
- 简化 useEffect 注释

适配新的状态管理模式,提升性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:46:17 +08:00
zdl
8db9a9429e refactor: 重构状态管理从 URL 驱动到本地状态
- 移除 getFiltersFromUrl 函数
- 添加 filters 本地状态(初始化时从 URL 读取)
- 重命名 updateUrlParams 为 updateFilters
- updateFilters 不再修改 URL,只更新本地状态
- 更新 loadEvents 使用本地 filters 依赖
- 移除 filterTags 中重复的 filters 声明

简化状态管理逻辑,避免 URL 和状态同步问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:39:45 +08:00
zdl
916537f25b refactor: 替换为统一搜索组件导入
- 移除旧组件导入: EventFilters, SearchBox, PopularKeywords, ImportanceLegend, InvestmentCalendar
- 添加 UnifiedSearchBox 组件导入(整合了多个组件功能)
- 移除未使用的 Chakra UI Link 组件导入
- 添加注释说明 Antd 组件占位符

为后续 JSX 布局重构做准备

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:34:40 +08:00
zdl
3d90ae7f74 feat: Community 页面引入 Redux 状态管理
- 添加 Redux hooks (useSelector, useDispatch)
- 导入 fetchPopularKeywords 和 fetchHotEvents action creators
- 移除本地状态 popularKeywords 和 hotEvents
- 移除 loadPopularKeywords 和 loadHotEvents 函数
- 使用 Redux dispatch 替代本地数据获取
- 利用 Redux 内置的缓存机制优化性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:33:39 +08:00
zdl
3580385967 feat: 添加行业分类Cascader组件
- 新增 IndustryCascader 组件,支持多级行业分类选择
- 集成 IndustryContext 全局行业数据管理
- 支持懒加载和搜索功能
- 提供清晰的行业选择路径展示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 14:22:18 +08:00
zdl
67c3d3a875 feat: 事件中心添加搜索框 2025-10-26 14:13:06 +08:00
zdl
65d0ec5354 feat: 调整关键字请求为外部传入 2025-10-26 14:11:54 +08:00
zdl
05307d6501 feat: 添加数据 2025-10-26 14:11:24 +08:00
zdl
a5702b631c feat: 调整依赖 2025-10-26 13:48:29 +08:00
zdl
a96f778779 feat: 主要优化点:
1. 消除 extraReducers 重复代码
       - 创建通用的 createDataReducers 工厂函数
       - 自动生成 pending/fulfilled/rejected cases
       - 减少约 30 行重复代码
     2. 创建独立的 CacheManager 类
       - 封装所有缓存操作(get/set/clear/isExpired)
       - 支持多种存储方式(localStorage/sessionStorage)
       - 更易于单元测试和 mock
     3. 添加请求去重机制
       - 使用 Promise 缓存防止重复请求
       - 同一时间多次调用只发起一次 API 请求
       - 提高性能,减少服务器负担
     4. 优化 Selectors(使用 reselect)
       - 添加 memoized selectors
       - 避免不必要的组件重新渲染
       - 提升性能
     5. 添加缓存预热功能
       - 应用启动时自动加载常用数据
       - 改善用户体验
2025-10-25 18:32:29 +08:00
zdl
0a0d617b20 feat: 添加行业筛选器Box 2025-10-25 18:23:20 +08:00
zdl
506f89e64e feat: 修复全局样式报错问题 2025-10-25 18:22:58 +08:00
zdl
094793c022 feat: 热门关键词UI调整 数据获取逻辑调整 接入redux 2025-10-25 18:22:41 +08:00
zdl
873adda1fd feat: 添加股票mock数据 2025-10-24 17:43:47 +08:00
zdl
b0ae5a2871 feat: 添加mock数据 2025-10-24 17:29:07 +08:00
zdl
6f34cab6d1 feat: 优化依赖 2025-10-24 17:18:08 +08:00
zdl
5aebd4b113 feat: 将 AppFooter 集成到 MainLayout 2025-10-24 17:17:31 +08:00
zdl
70f2676c79 feat: 添加appfooter 2025-10-24 17:10:29 +08:00
zdl
0b316a5ed8 feat: 优化依赖 2025-10-24 17:10:11 +08:00
zdl
72a009e1ae feat: session 添加节流检查 2025-10-24 17:09:42 +08:00
zdl
a92d556486 feat: 调整错误提示 2025-10-24 16:40:26 +08:00
6df66abcb4 调整socket对应的浏览器通知处理逻辑 2025-10-24 14:29:45 +08:00
16d04a6d28 调整socket对应的浏览器通知处理逻辑 2025-10-24 14:22:30 +08:00
zdl
3f881d000b feat: 添加修改行业分类不展示的问题 2025-10-24 13:30:52 +08:00
zdl
801113b7e5 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-24 12:54:54 +08:00
zdl
e0cd71880b feat: 申银万国数据分类调整 2025-10-24 12:54:42 +08:00
zdl
10a4dcb5d5 merge 2025-10-24 12:54:13 +08:00
zdl
9429eb0559 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature
# Conflicts:
#	src/views/Community/components/EventFilters.js
2025-10-24 12:37:35 +08:00
zdl
e69f822150 feat: user依赖优化 2025-10-24 12:34:43 +08:00
zdl
13c3c74b92 feat: 添加mock数据 2025-10-24 12:32:36 +08:00
zdl
bcf81f4d47 feat: 使用静态行业数据 2025-10-24 12:32:14 +08:00
zdl
f0d30244d2 feat: 添加重要性等级说明 2025-10-24 12:25:23 +08:00
zdl
f2cdc0756c feat: 添加行业静态数据 2025-10-24 12:21:22 +08:00
zdl
e91656d332 feat: user 依赖优化 2025-10-24 12:19:37 +08:00
62d6487cbb 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:47:48 +08:00
246adf4538 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:33:27 +08:00
8dcf643db7 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:27:45 +08:00
zdl
5eb4227e29 feat: 股票概览中心改为个股中心 2025-10-24 11:03:41 +08:00
zdl
34a6c402c4 feat: homeNavar 将投资日历从社区页面的右侧导航移到了顶部导航栏
InvestmentCalendar.js 将 loadEventCounts 函数改为使用 useCallback 包装
  - 修复了 useEffect 的依赖数组,添加了 loadEventCounts
  - 为事件列表 Modal 添加了 zIndex={1500}
  - 为内容详情 Drawer 添加了 zIndex={1500}
  - 为相关股票 Modal 添加了 zIndex={1500}
src/views/Community/components/RightSidebar.js

  修改内容:
  - 已删除此文件
2025-10-24 10:56:43 +08:00
zdl
6ad38594bb feat: 添加重要事件说明 2025-10-23 17:37:03 +08:00
zdl
1ba8b8fd2f feat: 消息通知能力测试 2025-10-23 15:25:36 +08:00
zdl
45b88309b3 pref: 代码优化 2025-10-23 15:03:39 +08:00
zdl
28975f74e9 feat: 将新闻中心改为事件中心 2025-10-23 14:57:26 +08:00
zdl
4eaeab521f feat: 事件请求防抖优化 2025-10-23 14:42:14 +08:00
zdl
9dcd4bfbf3 feat: 调整行业请求数据结构 2025-10-23 14:24:26 +08:00
zdl
d2988d1a33 feat: 增加券商名字段 2025-10-23 11:44:58 +08:00
74 changed files with 12206 additions and 2461 deletions

View File

@@ -4,7 +4,14 @@
"Read(//Users/qiye/**)",
"Bash(npm run lint:check)",
"Bash(npm run build)",
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)"
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)",
"Bash(node scripts/parseIndustryCSV.js)",
"Bash(cat:*)",
"Bash(npm cache clean --force)",
"Bash(npm install)",
"Bash(npm run start:mock)",
"Bash(npm install fsevents@latest --save-optional --force)",
"Bash(python -m py_compile:*)"
],
"deny": [],
"ask": []

Binary file not shown.

649
app.py
View File

@@ -101,7 +101,7 @@ def get_trading_day_near_date(target_date):
load_trading_days()
engine = create_engine(
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4",
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4",
echo=False,
pool_size=10,
pool_recycle=3600,
@@ -110,7 +110,7 @@ engine = create_engine(
max_overflow=20
)
engine_med = create_engine(
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/med?charset=utf8mb4",
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/med?charset=utf8mb4",
echo=False,
pool_size=5,
pool_recycle=3600,
@@ -119,7 +119,7 @@ engine_med = create_engine(
max_overflow=10
)
engine_2 = create_engine(
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/valuefrontier?charset=utf8mb4",
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier?charset=utf8mb4",
echo=False,
pool_size=5,
pool_recycle=3600,
@@ -204,7 +204,7 @@ app.config['COMPRESS_MIMETYPES'] = [
'application/javascript',
'application/x-javascript'
]
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4'
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10,
@@ -1849,6 +1849,15 @@ def send_verification_code():
if not credential or not code_type:
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
# 清理格式字符(空格、横线、括号等)
if code_type == 'phone':
# 移除手机号中的空格、横线、括号、加号等格式字符
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
print(f"📱 清理后的手机号: {credential}")
elif code_type == 'email':
# 邮箱只移除空格
credential = credential.strip()
# 生成验证码
verification_code = generate_verification_code()
@@ -1897,7 +1906,7 @@ def send_verification_code():
@app.route('/api/auth/login-with-code', methods=['POST'])
def login_with_verification_code():
"""使用验证码登录"""
"""使用验证码登录/注册(自动注册)"""
try:
data = request.get_json()
credential = data.get('credential') # 手机号或邮箱
@@ -1907,6 +1916,17 @@ def login_with_verification_code():
if not credential or not verification_code or not login_type:
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
# 清理格式字符(空格、横线、括号等)
if login_type == 'phone':
# 移除手机号中的空格、横线、括号、加号等格式字符
original_credential = credential
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
if original_credential != credential:
print(f"📱 登录时清理手机号: {original_credential} -> {credential}")
elif login_type == 'email':
# 邮箱只移除前后空格
credential = credential.strip()
# 检查验证码
session_key = f'verification_code_{login_type}_{credential}_login'
stored_code_info = session.get(session_key)
@@ -1932,13 +1952,86 @@ def login_with_verification_code():
# 验证码正确,查找用户
user = None
is_new_user = False
if login_type == 'phone':
user = User.query.filter_by(phone=credential).first()
if not user:
# 自动注册新用户
is_new_user = True
# 生成唯一用户名
base_username = f"user_{credential}"
username = base_username
counter = 1
while User.query.filter_by(username=username).first():
username = f"{base_username}_{counter}"
counter += 1
# 创建新用户
user = User(username=username, phone=credential)
user.phone_confirmed = True
user.email = f"{username}@valuefrontier.temp" # 临时邮箱
db.session.add(user)
db.session.commit()
elif login_type == 'email':
user = User.query.filter_by(email=credential).first()
if not user:
# 自动注册新用户
is_new_user = True
# 从邮箱生成用户名
email_prefix = credential.split('@')[0]
base_username = f"user_{email_prefix}"
username = base_username
counter = 1
while User.query.filter_by(username=username).first():
username = f"{base_username}_{counter}"
counter += 1
# 如果用户不存在,自动创建新用户
if not user:
return jsonify({'success': False, 'error': '用户不存在'}), 404
try:
# 生成用户名
if login_type == 'phone':
# 使用手机号生成用户名
base_username = f"用户{credential[-4:]}"
elif login_type == 'email':
# 使用邮箱前缀生成用户名
base_username = credential.split('@')[0]
else:
base_username = "新用户"
# 确保用户名唯一
username = base_username
counter = 1
while User.is_username_taken(username):
username = f"{base_username}_{counter}"
counter += 1
# 创建新用户
user = User(username=username)
# 设置手机号或邮箱
if login_type == 'phone':
user.phone = credential
elif login_type == 'email':
user.email = credential
# 设置默认密码(使用随机密码,用户后续可以修改)
user.set_password(uuid.uuid4().hex)
user.status = 'active'
user.nickname = username
db.session.add(user)
db.session.commit()
is_new_user = True
print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}")
except Exception as e:
print(f"❌ 创建用户失败: {e}")
db.session.rollback()
return jsonify({'success': False, 'error': '创建用户失败'}), 500
# 清除验证码
session.pop(session_key, None)
@@ -1955,9 +2048,13 @@ def login_with_verification_code():
# 更新最后登录时间
user.update_last_seen()
# 根据是否为新用户返回不同的消息
message = '注册成功,欢迎加入!' if is_new_user else '登录成功'
return jsonify({
'success': True,
'message': '登录成功',
'message': message,
'is_new_user': is_new_user,
'user': {
'id': user.id,
'username': user.username,
@@ -1971,6 +2068,7 @@ def login_with_verification_code():
except Exception as e:
print(f"验证码登录错误: {e}")
db.session.rollback()
return jsonify({'success': False, 'error': '登录失败'}), 500
@@ -2023,8 +2121,8 @@ def register():
except Exception as e:
db.session.rollback()
print(f"注册失败: {e}")
return jsonify({'success': False, 'error': '注册失败,请重试'}), 500
print(f"验证码登录/注册错误: {e}")
return jsonify({'success': False, 'error': '登录失败'}), 500
def send_sms_code(phone, code, template_id):
@@ -2628,8 +2726,19 @@ def wechat_callback():
state = request.args.get('state')
error = request.args.get('error')
# 错误处理
if error or not code or not state:
# 错误处理:用户拒绝授权
if error:
if state in wechat_qr_sessions:
wechat_qr_sessions[state]['status'] = 'auth_denied'
wechat_qr_sessions[state]['error'] = '用户拒绝授权'
print(f"❌ 用户拒绝授权: state={state}")
return redirect('/auth/signin?error=wechat_auth_denied')
# 参数验证
if not code or not state:
if state in wechat_qr_sessions:
wechat_qr_sessions[state]['status'] = 'auth_failed'
wechat_qr_sessions[state]['error'] = '授权参数缺失'
return redirect('/auth/signin?error=wechat_auth_failed')
# 验证state
@@ -2644,14 +2753,28 @@ def wechat_callback():
return redirect('/auth/signin?error=session_expired')
try:
# 获取access_token
# 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权)
session_data['status'] = 'scanned'
print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...")
# 步骤2: 获取access_token
token_data = get_wechat_access_token(code)
if not token_data:
session_data['status'] = 'auth_failed'
session_data['error'] = '获取访问令牌失败'
print(f"❌ 获取微信access_token失败: state={state}")
return redirect('/auth/signin?error=token_failed')
# 获取用户信息
# 步骤3: Token获取成功标记为已授权
session_data['status'] = 'authorized'
print(f"✅ 微信授权成功: openid={token_data['openid']}")
# 步骤4: 获取用户信息
user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid'])
if not user_info:
session_data['status'] = 'auth_failed'
session_data['error'] = '获取用户信息失败'
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
return redirect('/auth/signin?error=userinfo_failed')
# 查找或创建用户 / 或处理绑定
@@ -2696,6 +2819,8 @@ def wechat_callback():
return redirect('/home?bind=failed')
user = None
is_new_user = False
if unionid:
user = User.query.filter_by(wechat_union_id=unionid).first()
if not user:
@@ -2726,6 +2851,9 @@ def wechat_callback():
db.session.add(user)
db.session.commit()
is_new_user = True
print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}")
# 更新最后登录时间
user.update_last_seen()
@@ -2739,18 +2867,30 @@ def wechat_callback():
# Flask-Login 登录
login_user(user, remember=True)
# 清理微信session(仅登录/注册流程清理;绑定流程在上方已处理,不在此处清理)
# 更新微信session状态,供前端轮询检测
if state in wechat_qr_sessions:
# 仅当不是绑定流程,或没有模式信息时清理
if not wechat_qr_sessions[state].get('mode'):
del wechat_qr_sessions[state]
session_item = wechat_qr_sessions[state]
# 仅处理登录/注册流程,不处理绑定流程
if not session_item.get('mode'):
# 更新状态和用户信息
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
session_item['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 直接跳转到首页
return redirect('/home')
except Exception as e:
print(f"❌ 微信登录失败: {e}")
import traceback
traceback.print_exc()
db.session.rollback()
# 更新session状态为失败
if state in wechat_qr_sessions:
wechat_qr_sessions[state]['status'] = 'auth_failed'
wechat_qr_sessions[state]['error'] = str(e)
return redirect('/auth/signin?error=login_failed')
@@ -2821,61 +2961,6 @@ def login_with_wechat():
}), 500
@app.route('/api/auth/register/wechat', methods=['POST'])
def register_with_wechat():
"""微信注册(保留用于特殊情况)"""
data = request.get_json()
session_id = data.get('session_id')
username = data.get('username')
password = data.get('password')
if not all([session_id, username, password]):
return jsonify({'error': '所有字段都是必填的'}), 400
# 验证session
session = wechat_qr_sessions.get(session_id)
if not session:
return jsonify({'error': '微信验证失败或状态无效'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'error': '用户名已存在'}), 400
# 检查微信OpenID是否已被其他用户使用
wechat_openid = session.get('wechat_openid')
wechat_unionid = session.get('wechat_unionid')
if wechat_unionid and User.query.filter_by(wechat_union_id=wechat_unionid).first():
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
if User.query.filter_by(wechat_open_id=wechat_openid).first():
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
# 创建用户
try:
wechat_info = session['user_info']
user = User(username=username)
user.set_password(password)
# 使用清理后的昵称
user.nickname = user._sanitize_nickname(wechat_info.get('nickname', username))
user.avatar_url = wechat_info.get('avatar_url')
user.wechat_open_id = wechat_openid
user.wechat_union_id = wechat_unionid
db.session.add(user)
db.session.commit()
# 清除session
del wechat_qr_sessions[session_id]
return jsonify({
'message': '注册成功',
'user': user.to_dict()
}), 201
except Exception as e:
db.session.rollback()
print(f"WeChat register error: {e}")
return jsonify({'error': '注册失败,请重试'}), 500
@app.route('/api/account/wechat/unbind', methods=['POST'])
def unbind_wechat_account():
"""解绑当前登录用户的微信"""
@@ -4576,8 +4661,8 @@ def get_stock_quotes():
def get_clickhouse_client():
return Cclient(
host='111.198.58.126',
port=18778,
host='222.128.1.157',
port=18000,
user='default',
password='Zzl33818!',
database='stock'
@@ -6324,196 +6409,100 @@ def parse_json_field(field_value):
# ==================== 行业API ====================
@app.route('/api/classifications', methods=['GET'])
def get_classifications():
"""获取所有行业分类系统"""
"""获取申银万国行业分类树形结构"""
try:
# 查询申银万国行业分类的所有数据
sql = """
SELECT DISTINCT f002v as classification_name
SELECT f003v as code, f004v as level1, f005v as level2, f006v as level3,f007v as level4
FROM ea_sector
WHERE f002v NOT IN ('指数成份股', '市场分类', '概念板块', '地区省市分类', '中上协行业分类')
ORDER BY classification_name \
WHERE f002v = '申银万国行业分类'
AND f003v IS NOT NULL
AND f004v IS NOT NULL
ORDER BY f003v
"""
result = db.session.execute(text(sql)).all()
classifications = [{'name': row.classification_name} for row in result]
# 构建树形结构
tree_dict = {}
return jsonify({
'success': True,
'data': classifications
})
for row in result:
code = row.code
level1 = row.level1
level2 = row.level2
level3 = row.level3
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
# 跳过空数据
if not level1:
continue
@app.route('/api/levels', methods=['GET'])
def get_industry_levels():
"""获取行业层级数据"""
try:
classification = request.args.get('classification')
level = request.args.get('level', type=int)
level1_name = request.args.get('level1_name', '')
level2_name = request.args.get('level2_name', '')
level3_name = request.args.get('level3_name', '')
if not classification or not level or level < 1 or level > 4:
return jsonify({
'success': False,
'error': 'Invalid parameters'
}), 400
# 层级到字段的映射
level_fields = {
1: "f004v",
2: "f005v",
3: "f006v",
4: "f007v"
}
field_name = level_fields[level]
# 构建查询
if level == 1:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {"classification": classification}
elif level == 2:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {"classification": classification, "level1_name": level1_name}
elif level == 3:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND f005v = :level2_name
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {
"classification": classification,
"level1_name": level1_name,
"level2_name": level2_name
}
elif level == 4:
sql = f"""
SELECT DISTINCT f003v as code,
{field_name} as name
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND f005v = :level2_name
AND f006v = :level3_name
AND {field_name} IS NOT NULL
ORDER BY name
"""
params = {
"classification": classification,
"level1_name": level1_name,
"level2_name": level2_name,
"level3_name": level3_name
}
results = db.session.execute(text(sql), params).all()
industries = [{"code": row.code, "name": row.name} for row in results if row.name]
return jsonify({
'success': True,
'data': industries
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/info', methods=['GET'])
def get_industry_info():
"""获取行业详细信息"""
try:
classification = request.args.get('classification')
code = request.args.get('code')
if not classification or not code:
return jsonify({
'success': False,
'error': 'Missing parameters'
}), 400
# 根据代码长度确定字段
if len(code) >= 8:
field_name = "f007v"
elif len(code) >= 6:
field_name = "f006v"
elif len(code) >= 4:
field_name = "f005v"
else:
field_name = "f004v"
sql = f"""
SELECT {field_name} as name,
f004v as level1_name,
f005v as level2_name,
f006v as level3_name,
f007v as level4_name
FROM ea_sector
WHERE f002v = :classification
AND f003v = :code
AND {field_name} IS NOT NULL
LIMIT 1
"""
result = db.session.execute(text(sql), {
"classification": classification,
"code": code
}).first()
if not result:
return jsonify({
'success': False,
'error': 'Industry not found'
}), 404
return jsonify({
'success': True,
'data': {
'name': result.name,
'code': code,
'classification': classification,
'hierarchy': {
'level1': result.level1_name,
'level2': result.level2_name,
'level3': result.level3_name,
'level4': result.level4_name
# 第一层
if level1 not in tree_dict:
# 获取第一层的code取前3位或前缀
level1_code = code[:3] if len(code) >= 3 else code
tree_dict[level1] = {
'value': level1_code,
'label': level1,
'children_dict': {}
}
# 第二层
if level2:
if level2 not in tree_dict[level1]['children_dict']:
# 获取第二层的code取前6位
level2_code = code[:6] if len(code) >= 6 else code
tree_dict[level1]['children_dict'][level2] = {
'value': level2_code,
'label': level2,
'children_dict': {}
}
# 第三层
if level3:
if level3 not in tree_dict[level1]['children_dict'][level2]['children_dict']:
tree_dict[level1]['children_dict'][level2]['children_dict'][level3] = {
'value': code,
'label': level3
}
# 转换为最终格式
result_list = []
for level1_name, level1_data in tree_dict.items():
level1_node = {
'value': level1_data['value'],
'label': level1_data['label']
}
# 处理第二层
if level1_data['children_dict']:
level1_children = []
for level2_name, level2_data in level1_data['children_dict'].items():
level2_node = {
'value': level2_data['value'],
'label': level2_data['label']
}
# 处理第三层
if level2_data['children_dict']:
level2_children = []
for level3_name, level3_data in level2_data['children_dict'].items():
level2_children.append({
'value': level3_data['value'],
'label': level3_data['label']
})
if level2_children:
level2_node['children'] = level2_children
level1_children.append(level2_node)
if level1_children:
level1_node['children'] = level1_children
result_list.append(level1_node)
return jsonify({
'success': True,
'data': result_list
})
except Exception as e:
@@ -6523,6 +6512,29 @@ def get_industry_info():
}), 500
@app.route('/api/stocklist', methods=['GET'])
def get_stock_list():
"""获取股票列表"""
try:
sql = """
SELECT DISTINCT SECCODE as code, SECNAME as name
FROM ea_stocklist
ORDER BY SECCODE
"""
result = db.session.execute(text(sql)).all()
stocks = [{'code': row.code, 'name': row.name} for row in result]
return jsonify(stocks)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/events', methods=['GET'], strict_slashes=False)
def api_get_events():
"""
@@ -6544,10 +6556,8 @@ def api_get_events():
date_range = request.args.get('date_range')
recent_days = request.args.get('recent_days', type=int)
# 行业筛选参数
industry_classification = request.args.get('industry_classification')
industry_code = request.args.get('industry_code')
industry_level = request.args.get('industry_level', type=int)
# 行业筛选参数(只支持申银万国行业分类)
industry_code = request.args.get('industry_code') # 申万行业代码,如 "S370502"
# 概念/标签筛选参数
tag = request.args.get('tag')
@@ -6593,12 +6603,28 @@ def api_get_events():
query = query.filter_by(importance=importance)
if creator_id:
query = query.filter_by(creator_id=creator_id)
# 新增:行业代码过滤(MySQL JSON对象数组模式
if industry_classification and industry_code:
json_path = f'$[*]."{industry_classification}"'
query = query.filter(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)")
).params(json_path=json_path, industry_code=json.dumps(industry_code))
# 新增:行业代码过滤(申银万国行业分类
if industry_code:
# related_industries 格式: [{"申银万国行业分类": "S370502"}, ...]
# 支持多个行业代码,用逗号分隔
json_path = '$[*]."申银万国行业分类"'
# 如果包含逗号,说明是多个行业代码
if ',' in industry_code:
codes = [code.strip() for code in industry_code.split(',') if code.strip()]
# 使用 OR 条件匹配任意一个行业代码
conditions = []
for code in codes:
conditions.append(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :code)")
.bindparams(json_path=json_path, code=json.dumps(code))
)
query = query.filter(db.or_(*conditions))
else:
# 单个行业代码
query = query.filter(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)")
).params(json_path=json_path, industry_code=json.dumps(industry_code))
# 新增:关键词/全文搜索过滤MySQL JSON
if search_query:
like_pattern = f"%{search_query}%"
@@ -6719,8 +6745,6 @@ def api_get_events():
applied_filters['start_date'] = start_date
if end_date:
applied_filters['end_date'] = end_date
if industry_classification:
applied_filters['industry_classification'] = industry_classification
if industry_code:
applied_filters['industry_code'] = industry_code
if tag:
@@ -7972,6 +7996,98 @@ def format_date(date_obj):
return str(date_obj)
def remove_cycles_from_sankey_flows(flows_data):
"""
移除Sankey图数据中的循环边确保数据是DAG有向无环图
使用拓扑排序算法检测循环优先保留flow_ratio高的边
Args:
flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys
Returns:
list of flows without cycles
"""
if not flows_data:
return flows_data
# 按flow_ratio降序排序优先保留重要的边
sorted_flows = sorted(
flows_data,
key=lambda x: x.get('flow_metrics', {}).get('flow_ratio', 0) or 0,
reverse=True
)
# 构建图的邻接表和入度表
def build_graph(flows):
graph = {} # node -> list of successors
in_degree = {} # node -> in-degree count
all_nodes = set()
for flow in flows:
source = flow['source']['node_name']
target = flow['target']['node_name']
all_nodes.add(source)
all_nodes.add(target)
if source not in graph:
graph[source] = []
graph[source].append(target)
if target not in in_degree:
in_degree[target] = 0
in_degree[target] += 1
if source not in in_degree:
in_degree[source] = 0
return graph, in_degree, all_nodes
# 使用Kahn算法检测是否有环
def has_cycle(graph, in_degree, all_nodes):
# 找到所有入度为0的节点
queue = [node for node in all_nodes if in_degree.get(node, 0) == 0]
visited_count = 0
while queue:
node = queue.pop(0)
visited_count += 1
# 访问所有邻居
for neighbor in graph.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# 如果访问的节点数等于总节点数,说明没有环
return visited_count < len(all_nodes)
# 逐个添加边,如果添加后产生环则跳过
result_flows = []
for flow in sorted_flows:
# 尝试添加这条边
temp_flows = result_flows + [flow]
# 检查是否产生环
graph, in_degree, all_nodes = build_graph(temp_flows)
# 复制in_degree用于检测因为检测过程会修改它
in_degree_copy = in_degree.copy()
if not has_cycle(graph, in_degree_copy, all_nodes):
# 没有产生环,可以添加
result_flows.append(flow)
else:
# 产生环,跳过这条边
print(f"Skipping edge that creates cycle: {flow['source']['node_name']} -> {flow['target']['node_name']}")
removed_count = len(flows_data) - len(result_flows)
if removed_count > 0:
print(f"Removed {removed_count} edges to eliminate cycles in Sankey diagram")
return result_flows
def get_report_type(date_str):
"""获取报告期类型"""
if not date_str:
@@ -10220,7 +10336,7 @@ def get_daily_top_concepts():
limit = request.args.get('limit', 6, type=int)
# 构建概念中心API的URL
concept_api_url = 'http://111.198.58.126:16801/search'
concept_api_url = 'http://222.128.1.157:16801/search'
# 准备请求数据
request_data = {
@@ -10682,6 +10798,9 @@ def get_value_chain_analysis(company_code):
}
})
# 移除循环边确保Sankey图数据是DAG有向无环图
flows_data = remove_cycles_from_sankey_flows(flows_data)
# 统计各层级节点数量
level_stats = {}
for level_key, nodes in nodes_by_level.items():

View File

@@ -20,6 +20,7 @@
"@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",
"@tippyjs/react": "^4.2.6",
"@visx/visx": "^3.12.0",
@@ -59,6 +60,7 @@
"react-leaflet": "^3.2.5",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0-beta.4",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.1",
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",
@@ -142,5 +144,8 @@
"workerDirectory": [
"public"
]
},
"optionalDependencies": {
"fsevents": "^2.3.3"
}
}

View File

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

View File

@@ -40,10 +40,15 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
const EventDetail = React.lazy(() => import("views/EventDetail"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Redux
import { Provider as ReduxProvider } from 'react-redux';
import { store } from './store';
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
import { IndustryProvider } from "contexts/IndustryContext";
// Components
import ProtectedRoute from "components/ProtectedRoute";
@@ -290,28 +295,32 @@ export default function App() {
}, []);
return (
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<AuthProvider>
<AuthModalProvider>
<NotificationProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</NotificationProvider>
</AuthModalProvider>
</AuthProvider>
<ReduxProvider store={store}>
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
</ChakraProvider>
</ReduxProvider>
);
}

View File

@@ -143,7 +143,10 @@ export default function AuthFormContent() {
return;
}
if (!/^1[3-9]\d{9}$/.test(credential)) {
// 清理手机号格式字符(空格、横线、括号等)
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
toast({
title: "请输入有效的手机号",
status: "warning",
@@ -156,7 +159,7 @@ export default function AuthFormContent() {
setSendingCode(true);
const requestData = {
credential: credential.trim(), // 添加 trim() 防止空格
credential: cleanedCredential, // 使用清理后的手机号
type: 'phone',
purpose: config.api.purpose
};
@@ -189,13 +192,13 @@ export default function AuthFormContent() {
if (response.ok && data.success) {
// ❌ 移除成功 toast静默处理
logger.info('AuthFormContent', '验证码发送成功', {
credential: credential.substring(0, 3) + '****' + credential.substring(7),
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
dev_code: data.dev_code
});
// ✅ 开发环境下在控制台显示验证码
if (data.dev_code) {
console.log(`%c✅ [验证码] ${credential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
}
setVerificationCodeSent(true);
@@ -205,7 +208,7 @@ export default function AuthFormContent() {
}
} catch (error) {
logger.api.error('POST', '/api/auth/send-verification-code', error, {
credential: credential.substring(0, 3) + '****' + credential.substring(7)
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
});
// ✅ 显示错误提示给用户
@@ -247,7 +250,10 @@ export default function AuthFormContent() {
return;
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
// 清理手机号格式字符(空格、横线、括号等)
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
toast({
title: "请输入有效的手机号",
status: "warning",
@@ -258,13 +264,13 @@ export default function AuthFormContent() {
// 构建请求体
const requestBody = {
credential: phone.trim(), // 添加 trim() 防止空格
credential: cleanedPhone, // 使用清理后的手机号
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
login_type: 'phone',
};
logger.api.request('POST', '/api/auth/login-with-code', {
credential: phone.substring(0, 3) + '****' + phone.substring(7),
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
verification_code: verificationCode.substring(0, 2) + '****',
login_type: 'phone'
});

View File

@@ -15,6 +15,8 @@ import { FaQrcode } from "react-icons/fa";
import { FiAlertCircle } from "react-icons/fi";
import { useNavigate } from "react-router-dom";
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useAuth } from "../../contexts/AuthContext";
import { logger } from "../../utils/logger";
// 配置常量
@@ -33,6 +35,8 @@ const getStatusColor = (status) => {
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字
default: return "gray.600";
}
};
@@ -45,6 +49,10 @@ const getStatusText = (status) => {
};
export default function WechatRegister() {
// 获取关闭弹窗方法
const { closeModal } = useAuthModal();
const { refreshSession } = useAuth();
// 状态管理
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
const [wechatSessionId, setWechatSessionId] = useState("");
@@ -58,6 +66,7 @@ export default function WechatRegister() {
const timeoutRef = useRef(null);
const isMountedRef = useRef(true); // 追踪组件挂载状态
const containerRef = useRef(null); // 容器DOM引用
const sessionIdRef = useRef(null); // 存储最新的 sessionId避免闭包陷阱
const navigate = useNavigate();
const toast = useToast();
@@ -90,6 +99,7 @@ export default function WechatRegister() {
/**
* 清理所有定时器
* 注意:不清理 sessionIdRef因为 startPolling 时也会调用此函数
*/
const clearTimers = useCallback(() => {
if (pollIntervalRef.current) {
@@ -124,14 +134,14 @@ export default function WechatRegister() {
}
showSuccess(
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "注册成功",
"正在跳转..."
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
);
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
navigate("/home");
}, 1000);
// 刷新 AuthContext 状态
await refreshSession();
// 关闭认证弹窗,留在当前页面
closeModal();
} else {
throw new Error(response?.error || '登录失败');
}
@@ -139,17 +149,27 @@ export default function WechatRegister() {
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
showError("登录失败", error.message || "请重试");
}
}, [navigate, showSuccess, showError]);
}, [showSuccess, showError, closeModal, refreshSession]);
/**
* 检查微信扫码状态
* 使用 sessionIdRef.current 避免闭包陷阱
*/
const checkWechatStatus = useCallback(async () => {
// 检查组件是否已卸载
if (!isMountedRef.current || !wechatSessionId) return;
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
if (!isMountedRef.current || !sessionIdRef.current) {
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
isMounted: isMountedRef.current,
hasSessionId: !!sessionIdRef.current
});
return;
}
const currentSessionId = sessionIdRef.current;
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
try {
const response = await authService.checkWechatStatus(wechatSessionId);
const response = await authService.checkWechatStatus(currentSessionId);
// 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') {
@@ -158,6 +178,7 @@ export default function WechatRegister() {
}
const { status } = response;
logger.debug('WechatRegister', '微信状态', { status });
// 组件卸载后不再更新状态
if (!isMountedRef.current) return;
@@ -167,23 +188,14 @@ export default function WechatRegister() {
// 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
clearTimers(); // 停止轮询
sessionIdRef.current = null; // 清理 sessionId
// 显示"扫码成功,登录中"提示
if (isMountedRef.current) {
toast({
title: "扫码成功",
description: "正在登录,请稍候...",
status: "info",
duration: 2000,
isClosable: false,
});
}
await handleLoginSuccess(wechatSessionId, status);
await handleLoginSuccess(currentSessionId, status);
}
// 处理过期状态
else if (status === WECHAT_STATUS.EXPIRED) {
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
if (isMountedRef.current) {
toast({
title: "授权已过期",
@@ -194,12 +206,40 @@ export default function WechatRegister() {
});
}
}
// 处理用户拒绝授权
else if (status === WECHAT_STATUS.AUTH_DENIED) {
clearTimers();
if (isMountedRef.current) {
toast({
title: "授权已取消",
description: "您已取消微信授权登录",
status: "warning",
duration: 3000,
isClosable: true,
});
}
}
// 处理授权失败
else if (status === WECHAT_STATUS.AUTH_FAILED) {
clearTimers();
if (isMountedRef.current) {
const errorMsg = response.error || "授权过程出现错误";
toast({
title: "授权失败",
description: errorMsg,
status: "error",
duration: 5000,
isClosable: true,
});
}
}
} catch (error) {
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: wechatSessionId });
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
// 轮询过程中的错误不显示给用户,避免频繁提示
// 但如果错误持续发生,停止轮询避免无限重试
if (error.message.includes('网络连接失败')) {
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
if (isMountedRef.current) {
toast({
title: "网络连接失败",
@@ -211,12 +251,17 @@ export default function WechatRegister() {
}
}
}
}, [wechatSessionId, handleLoginSuccess, clearTimers, toast]);
}, [handleLoginSuccess, clearTimers, toast]);
/**
* 启动轮询
*/
const startPolling = useCallback(() => {
logger.debug('WechatRegister', '启动轮询', {
sessionId: sessionIdRef.current,
interval: POLL_INTERVAL
});
// 清理旧的定时器
clearTimers();
@@ -227,7 +272,9 @@ export default function WechatRegister() {
// 设置超时
timeoutRef.current = setTimeout(() => {
logger.debug('WechatRegister', '二维码超时');
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
setWechatStatus(WECHAT_STATUS.EXPIRED);
}, QR_CODE_TIMEOUT);
}, [checkWechatStatus, clearTimers]);
@@ -254,10 +301,17 @@ export default function WechatRegister() {
throw new Error(response.message || '获取二维码失败');
}
// 同时更新 ref 和 state确保轮询能立即读取到最新值
sessionIdRef.current = response.data.session_id;
setWechatAuthUrl(response.data.auth_url);
setWechatSessionId(response.data.session_id);
setWechatStatus(WECHAT_STATUS.WAITING);
logger.debug('WechatRegister', '获取二维码成功', {
sessionId: response.data.session_id,
authUrl: response.data.auth_url
});
// 启动轮询检查扫码状态
startPolling();
} catch (error) {
@@ -293,43 +347,10 @@ export default function WechatRegister() {
return () => {
isMountedRef.current = false;
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
};
}, [clearTimers]);
/**
* 备用轮询机制 - 防止丢失状态
* 每3秒检查一次仅在获取到二维码URL且状态为waiting时执行
*/
useEffect(() => {
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
logger.debug('WechatRegister', '备用轮询:启动备用轮询机制');
backupPollIntervalRef.current = setInterval(() => {
try {
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
logger.debug('WechatRegister', '备用轮询:检查微信状态');
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
checkWechatStatus().catch(error => {
logger.warn('WechatRegister', '备用轮询检查失败(静默处理)', { error: error.message });
});
}
} catch (error) {
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
logger.warn('WechatRegister', '备用轮询执行出错(静默处理)', { error: error.message });
}
}, BACKUP_POLL_INTERVAL);
}
// 清理备用轮询
return () => {
if (backupPollIntervalRef.current) {
clearInterval(backupPollIntervalRef.current);
backupPollIntervalRef.current = null;
}
};
}, [wechatAuthUrl, wechatSessionId, wechatStatus, checkWechatStatus]);
/**
* 测量容器尺寸并计算缩放比例
*/
@@ -397,7 +418,7 @@ export default function WechatRegister() {
textAlign="center"
mb={3} // 12px底部间距
>
微信扫码
微信登陆
</Heading>
{/* ========== 二维码区域 ========== */}
@@ -414,19 +435,26 @@ export default function WechatRegister() {
bg="gray.50"
boxShadow="sm" // ✅ 添加轻微阴影
>
{wechatStatus === WECHAT_STATUS.WAITING ? (
{wechatStatus !== WECHAT_STATUS.NONE ? (
/* 已获取二维码显示iframe */
<iframe
src={wechatAuthUrl}
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
style={{
border: 'none',
transform: 'scale(0.77) translateY(-20px)', // ✅ 裁剪顶部logo
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transformOrigin: 'top left',
marginLeft: '-5px'
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -53,10 +53,18 @@ const CitationMark = ({ citationId, citation }) => {
paddingBottom: 8,
borderBottom: '1px solid #f0f0f0'
}}>
{/* 左侧:作者 */}
{/* 左侧:券商 · 作者(或仅作者) */}
<Space size={4}>
<UserOutlined style={{ color: '#1890ff', fontSize: 12 }} />
<Text style={{ fontSize: 12, color: '#595959' }}>
{citation.organization && (
<>
<Text strong style={{ fontSize: 12, color: '#262626' }}>
{citation.organization}
</Text>
<Text style={{ margin: '0 4px', color: '#bfbfbf' }}> · </Text>
</>
)}
{citation.author}
</Text>
</Space>

View File

@@ -1,14 +1,14 @@
import React from 'react';
import {
Box,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
VStack,
Container
} from '@chakra-ui/react';
// import {
// Box,
// Alert,
// AlertIcon,
// AlertTitle,
// AlertDescription,
// Button,
// VStack,
// Container
// } from '@chakra-ui/react';
import { logger } from '../utils/logger';
class ErrorBoundary extends React.Component {
@@ -18,31 +18,21 @@ class ErrorBoundary extends React.Component {
}
static getDerivedStateFromError(error) {
// 开发环境:不拦截错误,让 React DevTools 显示完整堆栈
if (process.env.NODE_ENV === 'development') {
return { hasError: false };
}
// 生产环境:拦截错误,显示友好界面
// 所有环境都捕获错误,避免无限重渲染
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 开发环境:打印错误到控制台,但不显示错误边界
if (process.env.NODE_ENV === 'development') {
logger.error('ErrorBoundary', 'componentDidCatch', error, {
componentStack: errorInfo.componentStack,
developmentMode: true
});
// 不更新 state让错误继续抛出
return;
}
// 生产环境:保存错误信息到 state
logger.error('ErrorBoundary', 'componentDidCatch', error, {
// 记录详细的错误日志
logger.error('ErrorBoundary', 'Component Error Caught', error, {
componentStack: errorInfo.componentStack,
productionMode: true
errorName: error.name,
errorMessage: error.message,
environment: process.env.NODE_ENV,
stack: error.stack
});
// 保存错误信息到 state
this.setState({
error: error,
errorInfo: errorInfo
@@ -50,57 +40,68 @@ class ErrorBoundary extends React.Component {
}
render() {
// 开发环境:直接渲染子组件,不显示错误边界
if (process.env.NODE_ENV === 'development') {
return this.props.children;
}
// 静默模式:捕获错误并记录日志(已在 componentDidCatch 中完成)
// 但继续渲染子组件,不显示错误页面
// 注意:如果组件因错误无法渲染,该区域可能显示为空白
// // 如果有错误,显示错误边界(所有环境)
// if (this.state.hasError) {
// return (
// <Container maxW="lg" py={20}>
// <VStack spacing={6}>
// <Alert status="error" borderRadius="lg" p={6}>
// <AlertIcon boxSize="24px" />
// <Box>
// <AlertTitle fontSize="lg" mb={2}>
// 页面出现错误!
// </AlertTitle>
// <AlertDescription>
// {process.env.NODE_ENV === 'development'
// ? '组件渲染时发生错误,请查看下方详情和控制台日志。'
// : '页面加载时发生了未预期的错误,请尝试刷新页面。'}
// </AlertDescription>
// </Box>
// </Alert>
// 生产环境:如果有错误,显示错误边界
if (this.state.hasError) {
return (
<Container maxW="lg" py={20}>
<VStack spacing={6}>
<Alert status="error" borderRadius="lg" p={6}>
<AlertIcon boxSize="24px" />
<Box>
<AlertTitle fontSize="lg" mb={2}>
页面出现错误
</AlertTitle>
<AlertDescription>
页面加载时发生了未预期的错误请尝试刷新页面
</AlertDescription>
</Box>
</Alert>
{process.env.NODE_ENV === 'development' && (
<Box
w="100%"
bg="gray.50"
p={4}
borderRadius="lg"
fontSize="sm"
overflow="auto"
maxH="200px"
>
<Box fontWeight="bold" mb={2}>错误详情:</Box>
<Box as="pre" whiteSpace="pre-wrap">
{this.state.error && this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</Box>
</Box>
)}
<Button
colorScheme="blue"
onClick={() => window.location.reload()}
>
重新加载页面
</Button>
</VStack>
</Container>
);
}
// {/* 开发环境显示详细错误信息 */}
// {process.env.NODE_ENV === 'development' && this.state.error && (
// <Box
// w="100%"
// bg="red.50"
// p={4}
// borderRadius="lg"
// fontSize="sm"
// overflow="auto"
// maxH="400px"
// border="1px"
// borderColor="red.200"
// >
// <Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
// <Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
// <Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
// {this.state.error.stack && (
// <Box mt={2} color="gray.700">{this.state.error.stack}</Box>
// )}
// {this.state.errorInfo && this.state.errorInfo.componentStack && (
// <>
// <Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
// <Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
// </>
// )}
// </Box>
// </Box>
// )}
// <Button
// colorScheme="blue"
// size="lg"
// onClick={() => window.location.reload()}
// >
// 重新加载页面
// </Button>
// </VStack>
// </Container>
// );
// }
return this.props.children;
}
}

View File

@@ -32,6 +32,12 @@ import {
useColorModeValue,
useToast,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
@@ -44,6 +50,7 @@ import { getApiBase } from '../../utils/apiConfig';
import SubscriptionButton from '../Subscription/SubscriptionButton';
import SubscriptionModal from '../Subscription/SubscriptionModal';
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
const SecondaryNav = ({ showCompletenessAlert }) => {
@@ -51,20 +58,22 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
const location = useLocation();
const navbarBg = useColorModeValue('gray.50', 'gray.700');
const itemHoverBg = useColorModeValue('white', 'gray.600');
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
// 定义二级导航结构
const secondaryNavConfig = {
'/community': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/concepts': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
@@ -108,7 +117,7 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
<Box
bg={navbarBg}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.600')}
borderColor={borderColorValue}
py={2}
position="sticky"
top={showCompletenessAlert ? "120px" : "60px"}
@@ -217,7 +226,7 @@ const MoreNavMenu = ({ isAuthenticated, user }) => {
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
@@ -301,6 +310,9 @@ const NavItems = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
@@ -333,7 +345,7 @@ const NavItems = ({ isAuthenticated, user }) => {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
@@ -452,7 +464,7 @@ const NavItems = ({ isAuthenticated, user }) => {
联系我们
</MenuButton>
<MenuList minW="260px" p={4}>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>敬请期待</Text>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>
</HStack>
@@ -477,6 +489,11 @@ export default function HomeNavbar() {
const brandHover = useColorModeValue('blue.600', 'blue.300');
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 添加调试信息
logger.debug('HomeNavbar', '组件渲染状态', {
hasUser: !!user,
@@ -521,6 +538,9 @@ export default function HomeNavbar() {
const WATCHLIST_PAGE_SIZE = 10;
const EVENTS_PAGE_SIZE = 8;
// 投资日历 Modal 状态
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
// 用户信息完整性状态
const [profileCompleteness, setProfileCompleteness] = useState(null);
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
@@ -712,65 +732,81 @@ export default function HomeNavbar() {
error: error.message
});
}
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
}, [isAuthenticated, userId]); // ⚡ 使用 userId 而不是 user?.id
// 监听用户变化,重置检查标志(用户切换或退出登录时)
React.useEffect(() => {
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
}
}
}, [isAuthenticated, user?.id]); // 监听用户 ID 变化
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId
// 用户登录后检查资料完整性
React.useEffect(() => {
if (isAuthenticated && user) {
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if ((userIdChanged || authChanged) && isAuthenticated && user) {
// 延迟检查,避免过于频繁
const timer = setTimeout(checkProfileCompleteness, 1000);
return () => clearTimeout(timer);
}
}, [isAuthenticated, user?.id, checkProfileCompleteness]); // 只依赖 user.id,避免无限循环
}, [isAuthenticated, userId, checkProfileCompleteness, user]); // ⚡ 使用 userId
// 加载订阅信息
React.useEffect(() => {
if (isAuthenticated && user) {
const loadSubscriptionInfo = async () => {
try {
const base = getApiBase();
const response = await fetch(base + '/api/subscription/current', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 数据标准化处理确保type字段是小写的 'free', 'pro', 或 'max'
const normalizedData = {
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
status: data.data.status || 'active',
days_left: data.data.days_left || 0,
is_active: data.data.is_active !== false,
end_date: data.data.end_date || null
};
setSubscriptionInfo(normalizedData);
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
if (isAuthenticated && user) {
const loadSubscriptionInfo = async () => {
try {
const base = getApiBase();
const response = await fetch(base + '/api/subscription/current', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 数据标准化处理确保type字段是小写的 'free', 'pro', 或 'max'
const normalizedData = {
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
status: data.data.status || 'active',
days_left: data.data.days_left || 0,
is_active: data.data.is_active !== false,
end_date: data.data.end_date || null
};
setSubscriptionInfo(normalizedData);
}
}
} catch (error) {
logger.error('HomeNavbar', '加载订阅信息失败', error);
}
} catch (error) {
logger.error('HomeNavbar', '加载订阅信息失败', error);
}
};
loadSubscriptionInfo();
} else {
// 用户未登录时,重置为免费版
setSubscriptionInfo({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
};
loadSubscriptionInfo();
} else {
// 用户未登录时,重置为免费版
setSubscriptionInfo({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
}
}
}, [isAuthenticated, user?.id]); // 只依赖 user.id 而不是整个 user 对象
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId防重复通过 ref 判断
return (
<>
@@ -889,6 +925,20 @@ export default function HomeNavbar() {
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}>
{/* 投资日历 - 仅大屏显示 */}
{isDesktop && (
<Button
size="sm"
colorScheme="orange"
variant="solid"
borderRadius="full"
leftIcon={<FiCalendar />}
onClick={() => setCalendarModalOpen(true)}
>
投资日历
</Button>
)}
{/* 自选股 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadWatchlistQuotes}>
@@ -1155,6 +1205,11 @@ export default function HomeNavbar() {
<MenuDivider />
{/* 投资日历 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/community')}>
<Text>投资日历</Text>
</MenuItem>
{/* 自选股 */}
<MenuItem icon={<FiStar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
@@ -1352,7 +1407,7 @@ export default function HomeNavbar() {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">HOT</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
@@ -1516,6 +1571,22 @@ export default function HomeNavbar() {
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
{/* 投资日历 Modal */}
<Modal
isOpen={calendarModalOpen}
onClose={() => setCalendarModalOpen(false)}
size="6xl"
>
<ModalOverlay />
<ModalContent maxW="1200px">
<ModalHeader>投资日历</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<InvestmentCalendar />
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -299,8 +299,8 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
const isDark = useColorModeValue(false, true);
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
// 根据优先级调整背景色深度
const getPriorityBgColor = () => {
// 根据优先级调整背景色深度(使用 useMemo 缓存计算结果)
const priorityBgColor = useMemo(() => {
const colorScheme = typeConfig.colorScheme;
// 亮色模式:根据优先级使用不同深度的颜色
if (!isDark) {
@@ -323,31 +323,41 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
return 'gray.800';
}
}
};
}, [isDark, priority, typeConfig]);
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
// 颜色配置 - 支持亮色/暗色模式
// ⚠️ 必须在组件顶层调用 useColorModeValue不能在 useMemo 内部调用
const borderColor = useColorModeValue(
typeConfig.borderColor,
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
);
const iconColor = useColorModeValue(
typeConfig.iconColor,
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
);
const textColor = useColorModeValue('gray.800', 'gray.100');
const subTextColor = useColorModeValue('gray.600', 'gray.300');
const metaTextColor = useColorModeValue('gray.500', 'gray.500');
const hoverBgColor = useColorModeValue(
typeConfig.hoverBg,
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
);
const closeButtonHoverBgColor = useColorModeValue(
`${typeConfig.colorScheme}.200`,
`${typeConfig.colorScheme}.700`
);
// 使用 useMemo 缓存颜色对象(避免不必要的重新创建)
const colors = useMemo(() => ({
bg: getPriorityBgColor(),
border: useColorModeValue(
typeConfig.borderColor,
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
),
icon: useColorModeValue(
typeConfig.iconColor,
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
),
text: useColorModeValue('gray.800', 'gray.100'),
subText: useColorModeValue('gray.600', 'gray.300'),
metaText: useColorModeValue('gray.500', 'gray.500'),
hoverBg: useColorModeValue(
typeConfig.hoverBg,
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
),
closeButtonHoverBg: useColorModeValue(
`${typeConfig.colorScheme}.200`,
`${typeConfig.colorScheme}.700`
),
}), [isDark, priority, typeConfig]);
bg: priorityBgColor,
border: borderColor,
icon: iconColor,
text: textColor,
subText: subTextColor,
metaText: metaTextColor,
hoverBg: hoverBgColor,
closeButtonHoverBg: closeButtonHoverBgColor,
}), [priorityBgColor, borderColor, iconColor, textColor, subTextColor, metaTextColor, hoverBgColor, closeButtonHoverBgColor]);
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
const handleClick = useCallback(() => {
@@ -636,6 +646,11 @@ const NotificationContainer = () => {
}
}, [notifications]);
// ⚠️ 颜色配置 - 必须在条件return之前调用所有Hooks
const collapseBg = useColorModeValue('gray.100', 'gray.700');
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
// 如果没有通知,不渲染
if (notifications.length === 0) {
return null;
@@ -647,11 +662,6 @@ const NotificationContainer = () => {
const visibleNotifications = isExpanded ? notifications : notifications.slice(0, maxVisible);
const hiddenCount = notifications.length - maxVisible;
// 颜色配置
const collapseBg = useColorModeValue('gray.100', 'gray.700');
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
// 构建无障碍描述
const containerAriaLabel = hasMore
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航Enter键或空格键查看详情。`

View File

@@ -4,7 +4,7 @@
* 用于手动测试4种通知类型
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
@@ -16,8 +16,15 @@ import {
useDisclosure,
Badge,
Divider,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Code,
UnorderedList,
ListItem,
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment } from 'react-icons/md';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { SOCKET_TYPE } from '../../services/socket';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
@@ -27,6 +34,62 @@ const NotificationTestTool = () => {
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
const [testCount, setTestCount] = useState(0);
// 测试状态
const [isTestingNotification, setIsTestingNotification] = useState(false);
const [testCountdown, setTestCountdown] = useState(0);
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
// 系统环境检测
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
// 故障排查面板
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
// 检测系统环境
useEffect(() => {
// 检测是否为 macOS
const platform = navigator.platform.toLowerCase();
setIsMacOS(platform.includes('mac'));
// 检测全屏状态
const checkFullscreen = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', checkFullscreen);
checkFullscreen();
return () => {
document.removeEventListener('fullscreenchange', checkFullscreen);
};
}, []);
// 倒计时逻辑
useEffect(() => {
if (testCountdown > 0) {
const timer = setTimeout(() => {
setTestCountdown(testCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (testCountdown === 0 && isTestingNotification) {
// 倒计时结束,询问用户
setIsTestingNotification(false);
// 延迟一下再询问,确保用户有时间看到通知
setTimeout(() => {
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
setNotificationShown(sawNotification);
if (!sawNotification) {
// 没看到通知,展开故障排查面板
if (!isTroubleshootOpen) {
onTroubleshootToggle();
}
}
}, 500);
}
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
// 浏览器权限状态标签
const getPermissionLabel = () => {
switch (browserPermission) {
@@ -86,51 +149,6 @@ const NotificationTestTool = () => {
setTestCount(prev => prev + 1);
};
// 股票动向测试数据(涨)
const testStockAlertUp = () => {
addNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.URGENT,
title: '【测试】您关注的股票触发预警',
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=300750',
extra: {
stockCode: '300750',
stockName: '宁德时代',
priceChange: '+5.2%',
currentPrice: '245.50',
},
autoClose: 10000,
});
setTestCount(prev => prev + 1);
};
// 股票动向测试数据(跌)
const testStockAlertDown = () => {
addNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】您关注的股票异常波动',
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=002594',
extra: {
stockCode: '002594',
stockName: '比亚迪',
priceChange: '-3.8%',
currentPrice: '198.20',
},
autoClose: 10000,
});
setTestCount(prev => prev + 1);
};
// 事件动向测试数据
const testEventAlert = () => {
@@ -179,30 +197,6 @@ const NotificationTestTool = () => {
setTestCount(prev => prev + 1);
};
// AI分析报告测试数据
const testAIReport = () => {
addNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】AI产业链投资机会分析',
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
publishTime: Date.now(),
pushTime: Date.now(),
author: {
name: 'AI分析师',
organization: '价值前沿',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=test005',
extra: {
reportType: '策略报告',
industry: '人工智能',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
};
// 预测通知测试数据(不可跳转)
const testPrediction = () => {
@@ -272,42 +266,11 @@ const NotificationTestTool = () => {
}, 5000);
};
// 测试全部类型(层叠效果)
const testAllTypes = () => {
const tests = [testAnnouncement, testStockAlertUp, testEventAlert, testAnalysisReport];
tests.forEach((test, index) => {
setTimeout(() => test(), index * 600);
});
};
// 测试优先级
const testPriority = () => {
[
{ priority: PRIORITY_LEVELS.URGENT, label: '紧急' },
{ priority: PRIORITY_LEVELS.IMPORTANT, label: '重要' },
{ priority: PRIORITY_LEVELS.NORMAL, label: '普通' },
].forEach((item, index) => {
setTimeout(() => {
addNotification({
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: item.priority,
title: `【测试】${item.label}优先级通知`,
content: `这是一条${item.label}优先级的测试通知,用于验证优先级标签显示`,
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: false,
autoClose: 10000,
});
setTestCount(prev => prev + 1);
}, index * 600);
});
};
return (
<Box
position="fixed"
top="316px"
top="116px"
right={4}
zIndex={9998}
bg="white"
@@ -363,28 +326,6 @@ const NotificationTestTool = () => {
公告通知
</Button>
{/* 股票动向 */}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="red"
leftIcon={<MdTrendingUp />}
onClick={testStockAlertUp}
flex={1}
>
股票上涨
</Button>
<Button
size="sm"
colorScheme="green"
leftIcon={<MdTrendingUp style={{ transform: 'rotate(180deg)' }} />}
onClick={testStockAlertDown}
flex={1}
>
股票下跌
</Button>
</HStack>
{/* 事件动向 */}
<Button
size="sm"
@@ -396,27 +337,14 @@ const NotificationTestTool = () => {
</Button>
{/* 分析报告 */}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
flex={1}
>
分析报告
</Button>
<Badge colorScheme="purple" alignSelf="center">AI</Badge>
<Button
size="sm"
colorScheme="purple"
variant="outline"
onClick={testAIReport}
flex={1}
>
AI报告
</Button>
</HStack>
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
>
分析报告
</Button>
{/* 预测通知 */}
<Button
@@ -434,24 +362,6 @@ const NotificationTestTool = () => {
组合测试
</Text>
{/* 层叠测试 */}
<Button
size="sm"
colorScheme="teal"
onClick={testAllTypes}
>
层叠测试4种类型
</Button>
{/* 优先级测试 */}
<Button
size="sm"
colorScheme="pink"
onClick={testPriority}
>
优先级测试3个级别
</Button>
{/* 预测→详情流程测试 */}
<Button
size="sm"
@@ -479,6 +389,93 @@ const NotificationTestTool = () => {
</Button>
)}
{/* 测试浏览器通知按钮 */}
{browserPermission === 'granted' && (
<Button
size="sm"
colorScheme="green"
leftIcon={<MdNotifications />}
onClick={() => {
console.log('测试浏览器通知按钮被点击');
console.log('Notification support:', 'Notification' in window);
console.log('Notification permission:', Notification?.permission);
console.log('Platform:', navigator.platform);
console.log('Fullscreen:', !!document.fullscreenElement);
// 直接使用原生 Notification API 测试
if (!('Notification' in window)) {
alert('您的浏览器不支持桌面通知');
return;
}
if (Notification.permission !== 'granted') {
alert('浏览器通知权限未授予\n当前权限状态' + Notification.permission);
return;
}
// 重置状态
setNotificationShown(null);
setIsTestingNotification(true);
setTestCountdown(8); // 8秒倒计时
try {
console.log('正在创建浏览器通知...');
const notification = new Notification('【测试】浏览器通知测试', {
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
icon: '/logo192.png',
badge: '/badge.png',
tag: 'test_notification_' + Date.now(),
requireInteraction: false,
});
console.log('浏览器通知创建成功:', notification);
// 监听通知显示(成功显示)
notification.onshow = () => {
console.log('✅ 浏览器通知已显示onshow 事件触发)');
setNotificationShown(true);
};
// 监听通知错误
notification.onerror = (error) => {
console.error('❌ 浏览器通知错误:', error);
setNotificationShown(false);
};
// 监听通知关闭
notification.onclose = () => {
console.log('浏览器通知已关闭');
};
// 8秒后自动关闭
setTimeout(() => {
notification.close();
console.log('浏览器通知已自动关闭');
}, 8000);
// 点击通知时聚焦窗口
notification.onclick = () => {
console.log('浏览器通知被点击');
window.focus();
notification.close();
setNotificationShown(true);
};
setTestCount(prev => prev + 1);
} catch (error) {
console.error('创建浏览器通知失败:', error);
alert('创建浏览器通知失败:' + error.message);
setIsTestingNotification(false);
setNotificationShown(false);
}
}}
isLoading={isTestingNotification}
loadingText={`等待通知... ${testCountdown}s`}
>
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
@@ -491,6 +488,136 @@ const NotificationTestTool = () => {
</Text>
)}
{/* 实时权限状态 */}
<HStack spacing={2} justify="center">
<Text fontSize="xs" color="gray.500">
实际权限
</Text>
<Badge
colorScheme={
('Notification' in window && Notification.permission === 'granted') ? 'green' :
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
}
>
{('Notification' in window) ? Notification.permission : '不支持'}
</Badge>
</HStack>
{/* 环境警告 */}
{isFullscreen && (
<Alert status="warning" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">全屏模式</Text>
<Text>某些浏览器在全屏模式下不显示通知</Text>
</Box>
</Alert>
)}
{isMacOS && notificationShown === false && (
<Alert status="error" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">未检测到通知显示</Text>
<Text>可能是专注模式阻止了通知</Text>
</Box>
</Alert>
)}
<Divider />
{/* 故障排查面板 */}
<VStack spacing={2} align="stretch">
<Button
size="sm"
variant="outline"
colorScheme="orange"
leftIcon={<MdWarning />}
onClick={onTroubleshootToggle}
>
{isTroubleshootOpen ? '收起' : '故障排查指南'}
</Button>
<Collapse in={isTroubleshootOpen} animateOpacity>
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
<Text fontSize="xs" fontWeight="bold" color="orange.800">
如果看不到浏览器通知请检查
</Text>
{/* macOS 专注模式 */}
{isMacOS && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>点击右上角控制中心</ListItem>
<ListItem>关闭专注模式勿扰模式</ListItem>
<ListItem>或者系统设置 专注模式 关闭</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* macOS 系统通知设置 */}
{isMacOS && (
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>系统设置 通知</ListItem>
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> <Code fontSize="xs">Microsoft Edge</Code></ListItem>
<ListItem>确保允许通知已开启</ListItem>
<ListItem>通知样式设置为横幅提醒</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* Chrome 浏览器设置 */}
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
<ListItem>确保网站可以请求发送通知已开启</ListItem>
<ListItem>检查本站点是否在允许列表中</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
{/* 全屏模式提示 */}
{isFullscreen && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
<AlertDescription>
<Code fontSize="xs">ESC</Code> 退
</AlertDescription>
</Box>
</Alert>
)}
{/* 测试结果反馈 */}
{notificationShown === true && (
<Alert status="success" size="sm">
<AlertIcon />
<Text fontSize="xs"> 通知功能正常</Text>
</Alert>
)}
</VStack>
</Collapse>
</VStack>
<Divider />
{/* 功能按钮 */}

View File

@@ -1,5 +1,5 @@
// src/components/ProtectedRoute.js - 弹窗拦截版本
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import { useAuthModal } from '../contexts/AuthModalContext';
@@ -8,15 +8,17 @@ const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const { openAuthModal, isAuthModalOpen } = useAuthModal();
// 记录当前路径,登录成功后可以跳转回来
const currentPath = window.location.pathname + window.location.search;
// ⚡ 使用 useRef 保存当前路径,避免每次渲染创建新字符串导致 useEffect 无限循环
const currentPathRef = useRef(window.location.pathname + window.location.search);
// 未登录时自动弹出认证窗口
useEffect(() => {
if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) {
openAuthModal(currentPath);
openAuthModal(currentPathRef.current);
}
}, [isAuthenticated, user, isLoading, isAuthModalOpen, currentPath, openAuthModal]);
// ⚠️ 移除 user 依赖,因为 user 对象每次从 API 返回都是新引用,会导致无限循环
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, isLoading, isAuthModalOpen, openAuthModal]);
// 显示加载状态
if (isLoading) {

View File

@@ -0,0 +1,77 @@
// src/constants/importanceLevels.js
// 事件重要性等级配置
import {
WarningIcon,
WarningTwoIcon,
InfoIcon,
CheckCircleIcon,
} from '@chakra-ui/icons';
/**
* 重要性等级配置
* 用于事件列表展示和重要性说明
*/
export const IMPORTANCE_LEVELS = {
'S': {
level: 'S',
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
description: '重大事件,市场影响深远',
antdColor: '#722ed1', // 对应 Ant Design 的紫色
},
'A': {
level: 'A',
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
description: '重要事件,影响较大',
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
},
'B': {
level: 'B',
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
description: '普通事件,有一定影响',
antdColor: '#faad14', // 对应 Ant Design 的橙色
},
'C': {
level: 'C',
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
description: '参考事件,影响有限',
antdColor: '#52c41a', // 对应 Ant Design 的绿色
}
};
/**
* 获取重要性等级配置
* @param {string} importance - 重要性等级 (S/A/B/C)
* @returns {Object} 重要性配置对象
*/
export const getImportanceConfig = (importance) => {
return IMPORTANCE_LEVELS[importance] || IMPORTANCE_LEVELS['C'];
};
/**
* 获取所有等级配置(用于说明列表)
* @returns {Array} 所有等级配置数组
*/
export const getAllImportanceLevels = () => {
return Object.values(IMPORTANCE_LEVELS);
};

View File

@@ -3,6 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext';
// 创建认证上下文
const AuthContext = createContext();
@@ -23,11 +24,37 @@ export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const navigate = useNavigate();
const toast = useToast();
const { showWelcomeGuide } = useNotification();
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
const isAuthenticatedRef = React.useRef(isAuthenticated);
// ⚡ 请求节流:记录上次请求时间,防止短时间内重复请求
const lastCheckTimeRef = React.useRef(0);
const MIN_CHECK_INTERVAL = 1000; // 最少间隔1秒
// 检查Session状态
const checkSession = async () => {
// 节流检查
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTimeRef.current;
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
logger.warn('AuthContext', 'checkSession 请求被节流(防止频繁请求)', {
timeSinceLastCheck: `${timeSinceLastCheck}ms`,
minInterval: `${MIN_CHECK_INTERVAL}ms`,
reason: '距离上次请求间隔太短'
});
return;
}
lastCheckTimeRef.current = now;
try {
logger.debug('AuthContext', '检查Session状态');
logger.debug('AuthContext', '开始检查Session状态', {
timestamp: new Date().toISOString(),
timeSinceLastCheck: timeSinceLastCheck > 0 ? `${timeSinceLastCheck}ms` : '首次请求'
});
// 创建超时控制器
const controller = new AbortController();
@@ -55,19 +82,27 @@ export const AuthProvider = ({ children }) => {
});
if (data.isAuthenticated && data.user) {
setUser(data.user);
setIsAuthenticated(true);
// ⚡ 只在 user 数据真正变化时才更新状态,避免无限循环
setUser((prevUser) => {
// 比较用户 ID如果相同则不更新
if (prevUser && prevUser.id === data.user.id) {
return prevUser;
}
return data.user;
});
setIsAuthenticated((prev) => prev === true ? prev : true);
} else {
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
}
} catch (error) {
logger.error('AuthContext', 'checkSession', error);
// 网络错误或超时,设置为未登录状态
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
} finally {
setIsLoading(false);
// ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新
setIsLoading((prev) => prev === false ? prev : false);
}
};
@@ -77,11 +112,17 @@ export const AuthProvider = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ⚡ 同步 isAuthenticated 到 ref
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
// 监听路由变化检查session处理微信登录回调
// ⚡ 移除 isAuthenticated 依赖,使用 ref 避免重复注册事件监听器
useEffect(() => {
const handleRouteChange = () => {
// 如果是从微信回调返回的重新检查session
if (window.location.pathname === '/home' && !isAuthenticated) {
// 使用 ref 获取最新的认证状态
if (window.location.pathname === '/home' && !isAuthenticatedRef.current) {
checkSession();
}
};
@@ -89,7 +130,7 @@ export const AuthProvider = ({ children }) => {
window.addEventListener('popstate', handleRouteChange);
return () => window.removeEventListener('popstate', handleRouteChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
}, []); // ✅ 空依赖数组,只注册一次事件监听器
// 更新本地用户的便捷方法
const updateUser = (partial) => {
@@ -156,6 +197,11 @@ export const AuthProvider = ({ children }) => {
// isClosable: true,
// });
// ⚡ 登录成功后显示欢迎引导延迟2秒避免与登录Toast冲突
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -166,54 +212,6 @@ export const AuthProvider = ({ children }) => {
}
};
// 注册方法
const register = async (username, email, password) => {
try {
setIsLoading(true);
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('email', email);
formData.append('password', password);
const response = await fetch(`/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include',
body: formData
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
logger.error('AuthContext', 'register', error);
// ❌ 移除错误 toast静默失败
return { success: false, error: error.message };
} finally{
setIsLoading(false);
}
};
// 手机号注册
const registerWithPhone = async (phone, code, username, password) => {
@@ -252,6 +250,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -299,6 +302,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -414,7 +422,6 @@ export const AuthProvider = ({ children }) => {
isLoading,
updateUser,
login,
register,
registerWithPhone,
registerWithEmail,
sendSmsCode,

View File

@@ -0,0 +1,176 @@
// src/contexts/IndustryContext.js
// 行业分类数据全局上下文 - 使用API获取 + 缓存机制
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { industryData as staticIndustryData } from '../data/industryData';
import { industryService } from '../services/industryService';
import { logger } from '../utils/logger';
const IndustryContext = createContext();
// 缓存配置
const CACHE_KEY = 'industry_classifications_cache';
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 1天24小时
/**
* useIndustry Hook
* 在任何组件中使用行业数据
*/
export const useIndustry = () => {
const context = useContext(IndustryContext);
if (!context) {
throw new Error('useIndustry must be used within IndustryProvider');
}
return context;
};
/**
* 从 localStorage 读取缓存
*/
const loadFromCache = () => {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期1天
if (now - timestamp > CACHE_DURATION) {
localStorage.removeItem(CACHE_KEY);
logger.debug('IndustryContext', '缓存已过期,清除缓存');
return null;
}
logger.debug('IndustryContext', '从缓存加载行业数据', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + ' 分钟前'
});
return data;
} catch (error) {
logger.error('IndustryContext', 'loadFromCache', error);
return null;
}
};
/**
* 保存到 localStorage
*/
const saveToCache = (data) => {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('IndustryContext', '行业数据已缓存', {
count: data?.length || 0
});
} catch (error) {
logger.error('IndustryContext', 'saveToCache', error);
}
};
/**
* IndustryProvider 组件
* 提供全局行业数据管理 - 使用API获取 + 缓存机制
*/
export const IndustryProvider = ({ children }) => {
const [industryData, setIndustryData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const hasLoadedRef = useRef(false);
const isLoadingRef = useRef(false);
/**
* 加载行业数据
*/
const loadIndustryData = async () => {
// 防止重复加载(处理 StrictMode 双重调用)
if (hasLoadedRef.current || isLoadingRef.current) {
logger.debug('IndustryContext', '跳过重复加载', {
hasLoaded: hasLoadedRef.current,
isLoading: isLoadingRef.current
});
return industryData;
}
try {
isLoadingRef.current = true;
setLoading(true);
setError(null);
logger.debug('IndustryContext', '开始加载行业数据');
// 1. 先尝试从缓存加载
const cachedData = loadFromCache();
if (cachedData && cachedData.length > 0) {
setIndustryData(cachedData);
hasLoadedRef.current = true;
return cachedData;
}
// 2. 缓存不存在或过期,调用 API
logger.debug('IndustryContext', '缓存无效调用API获取数据');
const response = await industryService.getClassifications();
if (response.success && response.data && response.data.length > 0) {
setIndustryData(response.data);
saveToCache(response.data);
hasLoadedRef.current = true;
logger.debug('IndustryContext', 'API数据加载成功', {
count: response.data.length
});
return response.data;
} else {
throw new Error('API返回数据为空');
}
} catch (err) {
// 3. API 失败,回退到静态数据
logger.warn('IndustryContext', 'API加载失败使用静态数据', {
error: err.message
});
setError(err.message);
setIndustryData(staticIndustryData);
hasLoadedRef.current = true;
return staticIndustryData;
} finally {
setLoading(false);
isLoadingRef.current = false;
}
};
/**
* 刷新行业数据(清除缓存并重新加载)
*/
const refreshIndustryData = async () => {
logger.debug('IndustryContext', '刷新行业数据,清除缓存');
localStorage.removeItem(CACHE_KEY);
hasLoadedRef.current = false;
isLoadingRef.current = false;
return loadIndustryData();
};
// 组件挂载时自动加载数据
useEffect(() => {
loadIndustryData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const value = {
industryData,
loading,
error,
loadIndustryData,
refreshIndustryData
};
return (
<IndustryContext.Provider value={value}>
{children}
</IndustryContext.Provider>
);
};

View File

@@ -60,6 +60,8 @@ export const NotificationProvider = ({ children }) => {
const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity);
const audioRef = useRef(null);
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
const processedEventIds = useRef(new Set()); // 用于Socket层去重记录已处理的事件ID
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID避免内存泄漏
// ⚡ 使用权限引导管理 Hook
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
@@ -435,12 +437,23 @@ export const NotificationProvider = ({ children }) => {
* @param {object} notification - 通知对象
*/
const addNotification = useCallback(async (notification) => {
// ========== 显示层去重检查 ==========
const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 检查当前显示队列中是否已存在该通知
const isDuplicate = notifications.some(n => n.id === notificationId);
if (isDuplicate) {
logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId });
return notificationId; // 返回ID但不显示
}
// ========== 显示层去重检查结束 ==========
// 根据优先级获取自动关闭时长
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
const newNotification = {
id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
id: notificationId, // 使用预先生成的ID
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
@@ -453,106 +466,116 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Adding notification', newNotification);
// ========== 智能权限请求策略 ==========
// 首次收到重要/紧急通知时,自动请求桌面通知权限
if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) {
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'First important notification, requesting browser permission');
await requestBrowserPermission();
}
// 如果权限被拒绝,提示用户可以开启
else if (browserPermission === 'denied' && hasRequestedPermission) {
// 显示带"开启"按钮的 Toast仅重要/紧急通知)
const toastId = 'enable-notification-toast';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
title: newNotification.title,
description: '💡 开启桌面通知以便后台接收',
status: 'warning',
duration: 10000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
>
<HStack spacing={3} align="start">
<Box flex={1}>
<Text fontWeight="bold" mb={1}>
{newNotification.title}
</Text>
<Text fontSize="sm" opacity={0.9}>
💡 开启桌面通知以便后台接收
</Text>
</Box>
<Button
size="sm"
colorScheme="whiteAlpha"
onClick={() => {
requestBrowserPermission();
onClose();
}}
>
开启
</Button>
<CloseButton onClick={onClose} />
// ========== 增强权限请求策略 ==========
// 只要收到通知,就检查并提示用户授权
// 如果权限是default未授权自动请求
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
duration: 12000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
maxW="400px"
>
<VStack spacing={3} align="stretch">
<HStack spacing={2}>
<Icon as={BellIcon} boxSize={5} />
<Text fontWeight="bold" fontSize="md">
浏览器通知已被拒绝
</Text>
</HStack>
</Box>
),
});
}
<Text fontSize="sm" opacity={0.9}>
{newNotification.title}
</Text>
<Text fontSize="xs" opacity={0.8}>
💡 如需接收桌面通知请在浏览器设置中允许通知权限
</Text>
<VStack spacing={1} align="start" fontSize="xs" opacity={0.7}>
<Text>Chrome: 地址栏左侧 🔒 网站设置 通知</Text>
<Text>Safari: 偏好设置 网站 通知</Text>
<Text>Edge: 地址栏右侧 网站权限 通知</Text>
</VStack>
<Button
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={onClose}
alignSelf="flex-end"
>
知道了
</Button>
</VStack>
</Box>
),
});
}
}
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 智能分发策略 ==========
// ========== 分发策略(按优先级区分)- 已废弃 ==========
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
if (priority === PRIORITY_LEVELS.URGENT) {
logger.info('NotificationContext', 'Urgent notification: sending browser + web');
// 总是发送浏览器通知
sendBrowserNotification(newNotification);
// 如果在前台,也显示网页通知
if (!isPageHidden) {
addWebNotification(newNotification);
}
}
// if (priority === PRIORITY_LEVELS.URGENT) {
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
// // 总是发送浏览器通知
// sendBrowserNotification(newNotification);
// // 如果在前台,也显示网页通知
// if (!isPageHidden) {
// addWebNotification(newNotification);
// }
// }
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
else if (priority === PRIORITY_LEVELS.IMPORTANT) {
if (isPageHidden) {
logger.info('NotificationContext', 'Important notification (background): sending browser');
sendBrowserNotification(newNotification);
} else {
logger.info('NotificationContext', 'Important notification (foreground): sending web');
addWebNotification(newNotification);
}
}
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
// if (isPageHidden) {
// logger.info('NotificationContext', 'Important notification (background): sending browser');
// sendBrowserNotification(newNotification);
// } else {
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
// addWebNotification(newNotification);
// }
// }
// 策略 3: 普通通知 - 仅网页通知
else {
logger.info('NotificationContext', 'Normal notification: sending web only');
// else {
// logger.info('NotificationContext', 'Normal notification: sending web only');
// addWebNotification(newNotification);
// }
// ========== 新分发策略(仅区分前后台) ==========
if (isPageHidden) {
// 页面在后台:发送浏览器通知
logger.info('NotificationContext', 'Page hidden: sending browser notification');
sendBrowserNotification(newNotification);
} else {
// 页面在前台:发送网页通知
logger.info('NotificationContext', 'Page visible: sending web notification');
addWebNotification(newNotification);
}
return newNotification.id;
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
// 连接 socket
socket.connect();
// 获取并保存最大重连次数
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ✅ 第一步: 注册所有事件监听器
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
// 监听连接状态
socket.on('connect', () => {
@@ -560,6 +583,7 @@ export const NotificationProvider = ({ children }) => {
setIsConnected(true);
setReconnectAttempt(0);
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
if (wasDisconnected) {
@@ -624,6 +648,27 @@ export const NotificationProvider = ({ children }) => {
socket.on('new_event', (data) => {
logger.info('NotificationContext', 'Received new event', data);
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type}_${data.publishTime}`;
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
return; // 重复事件,直接忽略
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
// 限制Set大小避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS
});
}
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
const notification = adaptEventToNotification(data);
addNotification(notification);
@@ -635,6 +680,18 @@ export const NotificationProvider = ({ children }) => {
addNotification(data);
});
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
// ✅ 第二步: 获取最大重连次数
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ✅ 第三步: 调用 socket.connect()
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
socket.connect();
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
// 清理函数
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
@@ -652,7 +709,7 @@ export const NotificationProvider = ({ children }) => {
socket.off('system_notification');
socket.disconnect();
};
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
// ==================== 智能自动重试 ====================

4340
src/data/industryData.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,8 @@
*/
import { useEffect, useState, useRef } from 'react';
import { socketService } from '../services/socketService';
import socket from '../services/socket';
import { logger } from '../utils/logger';
export const useEventNotifications = (options = {}) => {
const {
@@ -60,29 +61,51 @@ export const useEventNotifications = (options = {}) => {
// 连接状态监听
const handleConnect = () => {
console.log('[useEventNotifications DEBUG] ✓ WebSocket 已连接');
logger.info('useEventNotifications', 'WebSocket connected');
setIsConnected(true);
setError(null);
};
const handleDisconnect = () => {
console.log('[useEventNotifications DEBUG] ⚠️ WebSocket 已断开');
logger.warn('useEventNotifications', 'WebSocket disconnected');
setIsConnected(false);
};
const handleConnectError = (err) => {
console.error('[useEventNotifications ERROR] WebSocket 连接错误:', err);
logger.error('useEventNotifications', 'WebSocket connect error', err);
setError(err);
setIsConnected(false);
};
// 监听连接事件必须在connect之前设置否则可能错过事件
socket.on('connect', handleConnect);
socket.on('disconnect', handleDisconnect);
socket.on('connect_error', handleConnectError);
// 连接 WebSocket
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
socketService.connect();
logger.info('useEventNotifications', 'Initializing WebSocket connection');
// 监听连接事件
socketService.on('connect', handleConnect);
socketService.on('disconnect', handleDisconnect);
socketService.on('connect_error', handleConnectError);
// 先检查是否已经连接
const alreadyConnected = socket.connected || false;
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
if (alreadyConnected) {
// 如果已经连接,直接更新状态
console.log('[useEventNotifications DEBUG] Socket已连接直接更新状态');
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
setIsConnected(true);
// 验证状态更新
setTimeout(() => {
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
}, 1000);
} else {
// 否则建立新连接
socket.connect();
}
// 新事件处理函数 - 使用 ref 中的回调
const handleNewEvent = (eventData) => {
@@ -113,21 +136,28 @@ export const useEventNotifications = (options = {}) => {
console.log('[useEventNotifications DEBUG] importance:', importance);
console.log('[useEventNotifications DEBUG] enabled:', enabled);
socketService.subscribeToEvents({
eventType,
importance,
onNewEvent: handleNewEvent,
onSubscribed: (data) => {
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
},
});
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
// 检查 socket 是否有 subscribeToEvents 方法mockSocketService 和 socketService 都有)
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType,
importance,
onNewEvent: handleNewEvent,
onSubscribed: (data) => {
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
},
});
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
} else {
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
}
// 保存取消订阅函数
unsubscribeRef.current = () => {
socketService.unsubscribeFromEvents({ eventType });
if (socket.unsubscribeFromEvents) {
socket.unsubscribeFromEvents({ eventType });
}
};
// 组件卸载时清理
@@ -142,18 +172,25 @@ export const useEventNotifications = (options = {}) => {
// 移除监听器
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
socketService.off('connect', handleConnect);
socketService.off('disconnect', handleDisconnect);
socketService.off('connect_error', handleConnectError);
// 断开连接
console.log('[useEventNotifications DEBUG] 断开 WebSocket 连接...');
socketService.disconnect();
socket.off('connect', handleConnect);
socket.off('disconnect', handleDisconnect);
socket.off('connect_error', handleConnectError);
// 注意:不断开连接,因为 socket 是全局共享的
// 由 NotificationContext 统一管理连接生命周期
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
};
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
// 监控 isConnected 状态变化(调试用)
useEffect(() => {
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
console.log('[useEventNotifications DEBUG] ===========================================');
}, [isConnected]);
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
return {
newEvent, // 最新收到的事件
isConnected, // WebSocket 连接状态

View File

@@ -1,5 +1,5 @@
// src/hooks/useSubscription.js
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { logger } from '../utils/logger';
@@ -104,10 +104,32 @@ export const useSubscription = () => {
}
};
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = useRef(userId);
const prevIsAuthenticatedRef = useRef(isAuthenticated);
useEffect(() => {
fetchSubscriptionInfo();
// ⚡ 只在 userId 或 isAuthenticated 真正变化时才请求
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
logger.debug('useSubscription', 'fetchSubscriptionInfo 触发', {
userIdChanged,
authChanged,
prevUserId: prevUserIdRef.current,
currentUserId: userId,
prevAuth: prevIsAuthenticatedRef.current,
currentAuth: isAuthenticated
});
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
fetchSubscriptionInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
}, [isAuthenticated, userId]); // 使用 userId 原始值,而不是 user?.id 表达式
// 获取订阅级别数值
const getSubscriptionLevel = (type = null) => {

32
src/layouts/AppFooter.js Normal file
View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
/**
* 应用通用页脚组件
* 包含版权信息、备案号等
*/
const AppFooter = () => {
return (
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="container.xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
);
};
export default AppFooter;

View File

@@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
import { Box } from '@chakra-ui/react';
import HomeNavbar from "../components/Navbars/HomeNavbar";
import PageLoader from "../components/Loading/PageLoader";
import AppFooter from "./AppFooter";
/**
* MainLayout - 带导航栏的主布局
@@ -15,17 +16,20 @@ import PageLoader from "../components/Loading/PageLoader";
*/
export default function MainLayout() {
return (
<Box minH="100vh">
<Box minH="100vh" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享,不会重新渲染 */}
<HomeNavbar />
{/* 页面内容区域 - 通过 Outlet 渲染当前路由对应的组件 */}
{/* Suspense 只包裹内容区域,导航栏保持可见 */}
<Box>
<Box flex="1">
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</Box>
{/* 页脚 - 在所有页面间共享 */}
<AppFooter />
</Box>
);
}

535
src/mocks/data/company.js Normal file
View File

@@ -0,0 +1,535 @@
// src/mocks/data/company.js
// 公司相关的 Mock 数据
// 平安银行 (000001) 的完整数据
export const PINGAN_BANK_DATA = {
stockCode: '000001',
stockName: '平安银行',
// 基本信息
basicInfo: {
code: '000001',
name: '平安银行',
english_name: 'Ping An Bank Co., Ltd.',
registered_capital: 1940642.3, // 万元
registered_capital_unit: '万元',
legal_representative: '谢永林',
general_manager: '谢永林',
secretary: '周强',
registered_address: '深圳市深南东路5047号',
office_address: '深圳市深南东路5047号',
zipcode: '518001',
phone: '0755-82080387',
fax: '0755-82080386',
email: 'ir@bank.pingan.com',
website: 'http://bank.pingan.com',
business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。',
employees: 36542,
introduction: '平安银行股份有限公司是中国平安保险集团股份有限公司控股的一家跨区域经营的股份制商业银行为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。',
list_date: '1991-04-03',
establish_date: '1987-12-22',
province: '广东省',
city: '深圳市',
industry: '银行',
main_business: '商业银行业务',
},
// 实际控制人信息
actualControl: {
controller_name: '中国平安保险(集团)股份有限公司',
controller_type: '企业',
shareholding_ratio: 52.38,
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
is_listed: true,
change_date: '2023-12-31',
remark: '中国平安通过直接和间接方式控股平安银行',
},
// 股权集中度
concentration: {
top1_ratio: 52.38,
top3_ratio: 58.42,
top5_ratio: 60.15,
top10_ratio: 63.28,
update_date: '2024-09-30',
concentration_level: '高度集中',
herfindahl_index: 0.2845,
},
// 高管信息
management: [
{
name: '谢永林',
position: '董事长、执行董事、行长',
gender: '男',
age: 56,
education: '硕士',
appointment_date: '2019-01-01',
annual_compensation: 723.8,
shareholding: 0,
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官'
},
{
name: '周强',
position: '执行董事、副行长、董事会秘书',
gender: '男',
age: 54,
education: '硕士',
appointment_date: '2016-06-01',
annual_compensation: 542.3,
shareholding: 0.002,
background: '历任平安银行深圳分行行长'
},
{
name: '郭世邦',
position: '执行董事、副行长、首席财务官',
gender: '男',
age: 52,
education: '博士',
appointment_date: '2018-03-01',
annual_compensation: 498.6,
shareholding: 0.001,
background: '历任中国平安集团财务负责人'
},
{
name: '蔡新发',
position: '副行长、首席风险官',
gender: '男',
age: 51,
education: '硕士',
appointment_date: '2017-05-01',
annual_compensation: 467.2,
shareholding: 0.0008,
background: '历任平安银行风险管理部总经理'
},
{
name: '项有志',
position: '副行长、首席信息官',
gender: '男',
age: 49,
education: '硕士',
appointment_date: '2019-09-01',
annual_compensation: 425.1,
shareholding: 0,
background: '历任中国平安科技公司总经理'
}
],
// 十大流通股东
topCirculationShareholders: [
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
],
// 十大股东(与流通股东相同,因为平安银行全流通)
topShareholders: [
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
],
// 分支机构
branches: [
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
],
// 公告列表
announcements: [
{
title: '平安银行股份有限公司2024年第三季度报告',
publish_date: '2024-10-28',
type: '定期报告',
summary: '2024年前三季度实现营业收入1245.6亿元同比增长8.2%净利润402.3亿元同比增长12.5%',
url: '/announcement/detail/ann_20241028_001'
},
{
title: '关于召开2024年第一次临时股东大会的通知',
publish_date: '2024-10-15',
type: '临时公告',
summary: '定于2024年11月5日召开2024年第一次临时股东大会审议关于调整董事会成员等议案',
url: '/announcement/detail/ann_20241015_001'
},
{
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
publish_date: '2024-09-20',
type: '临时公告',
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
url: '/announcement/detail/ann_20240920_001'
},
{
title: '平安银行股份有限公司2024年半年度报告',
publish_date: '2024-08-28',
type: '定期报告',
summary: '2024年上半年实现营业收入828.5亿元同比增长7.8%净利润265.4亿元同比增长11.2%',
url: '/announcement/detail/ann_20240828_001'
},
{
title: '关于2024年上半年利润分配预案的公告',
publish_date: '2024-08-20',
type: '分配方案',
summary: '拟以总股本194.06亿股为基数向全体股东每10股派发现金红利2.8元(含税)',
url: '/announcement/detail/ann_20240820_001'
}
],
// 披露时间表
disclosureSchedule: [
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
],
// 综合分析
comprehensiveAnalysis: {
overview: {
company_name: '平安银行股份有限公司',
stock_code: '000001',
industry: '银行',
established_date: '1987-12-22',
listing_date: '1991-04-03',
total_assets: 50245.6, // 亿元
net_assets: 3256.8,
registered_capital: 194.06,
employee_count: 36542
},
financial_highlights: {
revenue: 1623.5,
revenue_growth: 8.5,
net_profit: 528.6,
profit_growth: 12.3,
roe: 16.23,
roa: 1.05,
asset_quality_ratio: 1.02,
capital_adequacy_ratio: 13.45,
core_tier1_ratio: 10.82
},
business_structure: [
{ business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 },
{ business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 },
{ business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 }
],
competitive_advantages: [
'背靠中国平安集团,综合金融优势明显',
'零售业务转型成效显著,客户基础雄厚',
'金融科技创新能力强,数字化银行建设领先',
'风险管理体系完善,资产质量稳定',
'管理团队经验丰富,执行力强'
],
risk_factors: [
'宏观经济下行压力影响信贷质量',
'利率市场化导致息差收窄',
'金融监管趋严,合规成本上升',
'同业竞争激烈,市场份额面临挑战',
'金融科技发展带来的技术和运营风险'
],
development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力',
analyst_rating: {
buy: 18,
hold: 12,
sell: 2,
target_price: 15.8,
current_price: 13.2
}
},
// 价值链分析
valueChainAnalysis: {
upstream: [
{ name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' },
{ name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' },
{ name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' }
],
core_business: {
deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 },
loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 },
intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 },
digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 }
},
downstream: [
{ name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' },
{ name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' },
{ name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' }
],
ecosystem_partners: [
{ name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' },
{ name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' },
{ name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' },
{ name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' }
]
},
// 关键因素时间线
keyFactorsTimeline: [
{
date: '2024-10-28',
event: '发布2024年三季报',
type: '业绩公告',
importance: 'high',
impact: '前三季度净利润同比增长12.5%,超市场预期',
change: '+5.2%'
},
{
date: '2024-09-15',
event: '推出AI智能客服系统',
type: '科技创新',
importance: 'medium',
impact: '提升客户服务效率,降低运营成本',
change: '+2.1%'
},
{
date: '2024-08-28',
event: '发布2024年中报',
type: '业绩公告',
importance: 'high',
impact: '上半年净利润增长11.2%,资产质量保持稳定',
change: '+3.8%'
},
{
date: '2024-07-20',
event: '获批设立理财子公司',
type: '业务拓展',
importance: 'high',
impact: '完善财富管理业务布局,拓展收入来源',
change: '+4.5%'
},
{
date: '2024-06-10',
event: '完成300亿元二级资本债发行',
type: '融资事件',
importance: 'medium',
impact: '补充资本实力,支持业务扩张',
change: '+1.8%'
},
{
date: '2024-04-30',
event: '发布2024年一季报',
type: '业绩公告',
importance: 'high',
impact: '一季度净利润增长10.8%,开门红表现优异',
change: '+4.2%'
},
{
date: '2024-03-15',
event: '零售客户突破1.1亿户',
type: '业务里程碑',
importance: 'medium',
impact: '零售转型成效显著,客户基础进一步夯实',
change: '+2.5%'
},
{
date: '2024-01-20',
event: '获评"2023年度最佳零售银行"',
type: '荣誉奖项',
importance: 'low',
impact: '品牌影响力提升',
change: '+0.8%'
}
],
// 盈利预测报告
forecastReport: {
// 营收与利润趋势
income_profit_trend: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元)
profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元)
},
// 增长率分析
growth_bars: {
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%)
},
// EPS趋势
eps_trend: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS稀释元/股)
},
// PE与PEG分析
pe_peg_axes: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE
peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG
},
// 详细数据表格
detail_table: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
rows: [
{ '指标': '营业总收入(百万元)', '2020': 116524, '2021': 134632, '2022': 148956, '2023': 162350, '2024E': 175280, '2025E': 189450, '2026E': 204120 },
{ '指标': '营收增长率(%)', '2020': '-', '2021': 15.5, '2022': 10.6, '2023': 8.9, '2024E': 8.0, '2025E': 8.1, '2026E': 7.7 },
{ '指标': '归母净利润(百万元)', '2020': 34562, '2021': 39845, '2022': 43218, '2023': 52860, '2024E': 58420, '2025E': 64680, '2026E': 71250 },
{ '指标': '净利润增长率(%)', '2020': '-', '2021': 15.3, '2022': 8.5, '2023': 22.3, '2024E': 10.5, '2025E': 10.7, '2026E': 10.2 },
{ '指标': 'EPS(稀释,元)', '2020': 1.78, '2021': 2.05, '2022': 2.23, '2023': 2.72, '2024E': 3.01, '2025E': 3.33, '2026E': 3.67 },
{ '指标': 'ROE(%)', '2020': 14.2, '2021': 15.8, '2022': 15.5, '2023': 16.2, '2024E': 16.5, '2025E': 16.8, '2026E': 17.0 },
{ '指标': '总资产(百万元)', '2020': 4512360, '2021': 4856230, '2022': 4923150, '2023': 5024560, '2024E': 5230480, '2025E': 5445200, '2026E': 5668340 },
{ '指标': '净资产(百万元)', '2020': 293540, '2021': 312680, '2022': 318920, '2023': 325680, '2024E': 338560, '2025E': 352480, '2026E': 367820 },
{ '指标': '资产负债率(%)', '2020': 93.5, '2021': 93.6, '2022': 93.5, '2023': 93.5, '2024E': 93.5, '2025E': 93.5, '2026E': 93.5 },
{ '指标': 'PE(倍)', '2020': 7.4, '2021': 6.9, '2022': 7.2, '2023': 4.9, '2024E': 4.4, '2025E': 4.0, '2026E': 3.6 },
{ '指标': 'PB(倍)', '2020': 1.05, '2021': 1.09, '2022': 1.12, '2023': 0.79, '2024E': 0.72, '2025E': 0.67, '2026E': 0.61 }
]
}
}
};
// 生成通用公司数据的工具函数
export const generateCompanyData = (stockCode, stockName) => {
// 如果是平安银行,直接返回详细数据
if (stockCode === '000001') {
return PINGAN_BANK_DATA;
}
// 否则生成通用数据
return {
stockCode,
stockName,
basicInfo: {
code: stockCode,
name: stockName,
registered_capital: Math.floor(Math.random() * 500000) + 10000,
registered_capital_unit: '万元',
legal_representative: '张三',
general_manager: '李四',
secretary: '王五',
registered_address: '中国某省某市某区某路123号',
office_address: '中国某省某市某区某路123号',
phone: '021-12345678',
email: 'ir@company.com',
website: 'http://www.company.com',
employees: Math.floor(Math.random() * 10000) + 1000,
list_date: '2010-01-01',
industry: '制造业',
},
actualControl: {
controller_name: '某控股集团有限公司',
controller_type: '企业',
shareholding_ratio: 35.5,
control_chain: '某控股集团有限公司 -> ' + stockName,
},
concentration: {
top1_ratio: 35.5,
top3_ratio: 52.3,
top5_ratio: 61.8,
top10_ratio: 72.5,
concentration_level: '适度集中',
},
management: [
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 },
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 },
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 },
],
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
shareholder_name: `股东${i + 1}`,
shares: Math.floor(Math.random() * 100000000),
ratio: (10 - i) * 0.8,
change: Math.floor(Math.random() * 10000000) - 5000000,
shareholder_type: '企业'
})),
topShareholders: Array(10).fill(null).map((_, i) => ({
shareholder_name: `股东${i + 1}`,
shares: Math.floor(Math.random() * 100000000),
ratio: (10 - i) * 0.8,
change: Math.floor(Math.random() * 10000000) - 5000000,
shareholder_type: '企业',
is_restricted: false
})),
branches: [
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' },
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' },
],
announcements: [
{ title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' },
{ title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' },
],
disclosureSchedule: [
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
],
comprehensiveAnalysis: {
overview: {
company_name: stockName,
stock_code: stockCode,
industry: '制造业',
total_assets: Math.floor(Math.random() * 10000) + 100,
},
financial_highlights: {
revenue: Math.floor(Math.random() * 1000) + 50,
revenue_growth: (Math.random() * 20 - 5).toFixed(2),
net_profit: Math.floor(Math.random() * 100) + 10,
profit_growth: (Math.random() * 20 - 5).toFixed(2),
},
competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'],
risk_factors: ['市场竞争激烈', '原材料价格波动'],
},
valueChainAnalysis: {
upstream: [
{ name: '原材料供应商A', relationship: '供应商', importance: '高' },
{ name: '原材料供应商B', relationship: '供应商', importance: '中' },
],
downstream: [
{ name: '经销商网络', scale: '1000家', contribution: '60%' },
{ name: '直营渠道', scale: '100家', contribution: '40%' },
],
},
keyFactorsTimeline: [
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' },
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' },
],
// 通用预测报告数据
forecastReport: {
income_profit_trend: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
income: [5000, 5800, 6500, 7200, 7900, 8600, 9400],
profit: [450, 520, 580, 650, 720, 800, 890]
},
growth_bars: {
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3]
},
eps_trend: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89]
},
pe_peg_axes: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2],
peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20]
},
detail_table: {
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
rows: [
{ '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 },
{ '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 },
{ '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 },
{ '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 },
{ '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 },
{ '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 }
]
}
}
};
};

View File

@@ -545,3 +545,274 @@ export function getEventRelatedStocks(eventId) {
const count = 3 + (parseInt(eventId) % 4);
return generateRelatedStocks(eventId, count);
}
// ==================== Mock 事件列表数据 ====================
// 事件类型池
const eventTypes = ['政策发布', '行业动向', '公司公告', '市场研判', '技术突破', '财报发布', '投融资', '高管变动'];
// 行业池
const industries = ['半导体', '新能源', '人工智能', '医药', '消费', '金融', '房地产', '通信', '互联网', '军工', '化工', '机械'];
// 事件标题模板
const eventTitleTemplates = [
'{industry}行业迎来重大政策利好',
'{company}发布{quarter}财报,业绩超预期',
'{industry}板块集体大涨,{company}涨停',
'央行宣布{policy},影响{industry}行业',
'{company}与{partner}达成战略合作',
'{industry}技术取得重大突破',
'{company}拟投资{amount}亿元布局{industry}',
'国家发改委:支持{industry}产业发展',
'{industry}龙头{company}涨价{percent}%',
'{company}回购股份,彰显信心',
];
// 生成随机公司名
function generateCompanyName(industry) {
const prefixes = ['华为', '中兴', '阿里', '腾讯', '比亚迪', '宁德时代', '隆基', '恒瑞', '茅台', '五粮液', '海康', '中芯'];
const suffixes = ['科技', '集团', '股份', '控股', '实业', ''];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${prefix}${suffix}`;
}
// 生成事件标题
function generateEventTitle(industry, seed) {
const template = eventTitleTemplates[seed % eventTitleTemplates.length];
return template
.replace('{industry}', industry)
.replace('{company}', generateCompanyName(industry))
.replace('{partner}', generateCompanyName(industry))
.replace('{quarter}', ['一季度', '半年度', '三季度', '年度'][seed % 4])
.replace('{policy}', ['降准0.5%', '降息25BP', 'MLF下调', '提高赤字率'][seed % 4])
.replace('{amount}', [50, 100, 200, 500][seed % 4])
.replace('{percent}', [5, 10, 15, 20][seed % 4]);
}
// 生成事件描述
function generateEventDescription(industry, importance, seed) {
const impacts = {
S: '重大利好预计将对行业格局产生深远影响相关概念股有望持续受益。机构预计该事件将带动行业整体估值提升15-20%,龙头企业市值增长空间广阔。',
A: '重要利好,市场情绪积极,短期内资金流入明显。分析师普遍认为该事件将推动行业景气度上行,相关公司业绩有望超预期增长。',
B: '中性偏好,对部分细分领域有一定促进作用。虽然不是行业性机会,但优质标的仍有结构性行情,建议关注业绩确定性强的公司。',
C: '影响有限,市场反应平淡,但长期来看仍有积极意义。事件对行业发展方向有指引作用,关注后续政策跟进和落地情况。',
};
const details = [
`根据最新消息,${industry}领域将获得新一轮政策支持,产业链相关企业订单饱满。`,
`${industry}板块近期表现活跃,多只个股创出年内新高,资金持续流入。`,
`行业专家指出,${industry}产业正处于高速发展期,市场空间广阔,龙头企业优势明显。`,
`券商研报显示,${industry}行业估值处于历史低位,当前具备较高配置价值。`,
];
return impacts[importance] + details[seed % details.length];
}
// 生成关键词
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
const industryKeywords = {
'半导体': ['芯片', '晶圆', '封测', 'AI芯片', '国产替代'],
'新能源': ['电池', '光伏', '储能', '新能源车', '锂电'],
'人工智能': ['大模型', 'AI应用', '算力', '数据', '机器学习'],
'医药': ['创新药', 'CRO', '医疗器械', '生物制药', '仿制药'],
'消费': ['白酒', '食品', '家电', '零售', '免税'],
};
const keywords = [
...commonKeywords.slice(seed % 3, seed % 3 + 3),
...(industryKeywords[industry] || []).slice(0, 2)
];
return keywords.slice(0, 5);
}
/**
* 生成 Mock 事件列表
* @param {Object} params - 查询参数
* @returns {Object} - {events: [], pagination: {}}
*/
export function generateMockEvents(params = {}) {
const {
page = 1,
per_page = 10,
sort = 'new',
importance = 'all',
date_range = '',
q = '',
industry_code = '',
stock_code = '',
} = params;
// 生成100个事件用于测试
const totalEvents = 100;
const allEvents = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
const baseDate = new Date('2025-01-15');
for (let i = 0; i < totalEvents; i++) {
const industry = industries[i % industries.length];
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
// 生成随机日期最近30天内
const createdAt = new Date(baseDate);
createdAt.setDate(createdAt.getDate() - (i % 30));
// 生成随机热度和收益率
const hotScore = Math.max(50, 100 - i);
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
// 为每个事件随机选择2-5个相关股票
const relatedStockCount = 2 + (i % 4); // 2-5个股票
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
}
}
// 如果同行业股票不够,从整个 stockPool 中补充
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
const randomStock = stockPool[relatedStocks.length % stockPool.length];
if (!relatedStocks.includes(randomStock.stock_code)) {
relatedStocks.push(randomStock.stock_code);
}
}
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
description: generateEventDescription(industry, imp, i),
content: generateEventDescription(industry, imp, i),
event_type: eventType,
importance: imp,
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
hot_score: hotScore,
view_count: Math.floor(Math.random() * 10000),
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks, // 添加相关股票列表
});
}
// 筛选
let filteredEvents = allEvents;
// 重要性筛选
if (importance && importance !== 'all') {
filteredEvents = filteredEvents.filter(e => e.importance === importance);
}
// 关键词搜索
if (q) {
const query = q.toLowerCase();
filteredEvents = filteredEvents.filter(e =>
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
e.keywords.some(k => k.toLowerCase().includes(query))
);
}
// 行业筛选
if (industry_code) {
filteredEvents = filteredEvents.filter(e =>
e.industry.includes(industry_code) || e.keywords.includes(industry_code)
);
}
// 股票代码筛选
if (stock_code) {
// 移除可能的后缀 (.SH, .SZ)
const cleanStockCode = stock_code.replace(/\.(SH|SZ)$/, '');
filteredEvents = filteredEvents.filter(e => {
if (!e.related_stocks || e.related_stocks.length === 0) {
return false;
}
// 检查事件的 related_stocks 中是否包含该股票代码
return e.related_stocks.some(code => {
const cleanCode = code.replace(/\.(SH|SZ)$/, '');
return cleanCode === cleanStockCode || code === stock_code;
});
});
}
// 日期范围筛选
if (date_range) {
const [startStr, endStr] = date_range.split(' 至 ');
if (startStr && endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
filteredEvents = filteredEvents.filter(e => {
const eventDate = new Date(e.created_at);
return eventDate >= start && eventDate <= end;
});
}
}
// 排序
if (sort === 'hot') {
filteredEvents.sort((a, b) => b.hot_score - a.hot_score);
} else if (sort === 'returns') {
filteredEvents.sort((a, b) => b.related_avg_chg - a.related_avg_chg);
} else {
// 默认按时间排序 (new)
filteredEvents.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
// 分页
const start = (page - 1) * per_page;
const end = start + per_page;
const paginatedEvents = filteredEvents.slice(start, end);
return {
events: paginatedEvents,
pagination: {
page: page,
per_page: per_page,
total: filteredEvents.length,
total_pages: Math.ceil(filteredEvents.length / per_page),
},
};
}
/**
* 生成热点事件
* @param {number} limit - 返回数量
* @returns {Array} - 热点事件列表
*/
export function generateHotEvents(limit = 5) {
const { events } = generateMockEvents({ sort: 'hot', per_page: limit });
return events;
}
/**
* 生成热门关键词
* @param {number} limit - 返回数量
* @returns {Array} - 热门关键词列表
*/
export function generatePopularKeywords(limit = 20) {
const allKeywords = [
'人工智能', '芯片', '新能源', '锂电池', '光伏', '储能',
'消费', '白酒', '医药', 'CRO', '半导体', '国产替代',
'军工', '航空', '5G', '通信', '互联网', '云计算',
'大数据', '区块链', '元宇宙', '新基建', '数字经济',
];
return allKeywords.slice(0, limit).map((keyword, index) => ({
keyword,
count: Math.max(10, 100 - index * 3),
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
}));
}

139
src/mocks/data/financial.js Normal file
View File

@@ -0,0 +1,139 @@
// src/mocks/data/financial.js
// 财务数据相关的 Mock 数据
// 生成财务数据
export const generateFinancialData = (stockCode) => {
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
return {
stockCode,
// 股票基本信息
stockInfo: {
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例公司',
industry: stockCode === '000001' ? '银行' : '制造业',
list_date: '1991-04-03',
market: 'SZ'
},
// 资产负债表
balanceSheet: periods.map((period, i) => ({
period,
total_assets: 5024560 - i * 50000, // 百万元
total_liabilities: 4698880 - i * 48000,
shareholders_equity: 325680 - i * 2000,
current_assets: 2512300 - i * 25000,
non_current_assets: 2512260 - i * 25000,
current_liabilities: 3456780 - i * 35000,
non_current_liabilities: 1242100 - i * 13000
})),
// 利润表
incomeStatement: periods.map((period, i) => ({
period,
revenue: 162350 - i * 4000, // 百万元
operating_cost: 45620 - i * 1200,
gross_profit: 116730 - i * 2800,
operating_profit: 68450 - i * 1500,
net_profit: 52860 - i * 1200,
eps: 2.72 - i * 0.06
})),
// 现金流量表
cashflow: periods.map((period, i) => ({
period,
operating_cashflow: 125600 - i * 3000, // 百万元
investing_cashflow: -45300 - i * 1000,
financing_cashflow: -38200 + i * 500,
net_cashflow: 42100 - i * 1500,
cash_ending: 456780 - i * 10000
})),
// 财务指标
financialMetrics: periods.map((period, i) => ({
period,
roe: 16.23 - i * 0.3, // %
roa: 1.05 - i * 0.02,
gross_margin: 71.92 - i * 0.5,
net_margin: 32.56 - i * 0.3,
current_ratio: 0.73 + i * 0.01,
quick_ratio: 0.71 + i * 0.01,
debt_ratio: 93.52 + i * 0.05,
asset_turnover: 0.41 - i * 0.01,
inventory_turnover: 0, // 银行无库存
receivable_turnover: 0 // 银行特殊
})),
// 主营业务
mainBusiness: {
by_product: [
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
],
by_region: [
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
]
},
// 业绩预告
forecast: {
period: '2024',
forecast_net_profit_min: 580000, // 百万元
forecast_net_profit_max: 620000,
yoy_growth_min: 10.0, // %
yoy_growth_max: 17.0,
forecast_type: '预增',
reason: '受益于零售业务快速增长及资产质量改善,预计全年业绩保持稳定增长',
publish_date: '2024-10-15'
},
// 行业排名
industryRank: {
industry: '银行',
total_companies: 42,
rankings: [
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
]
},
// 期间对比
periodComparison: {
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
metrics: [
{
name: '营业收入',
unit: '百万元',
values: [41500, 40800, 40200, 40850],
yoy: [8.2, 7.8, 8.5, 9.2]
},
{
name: '净利润',
unit: '百万元',
values: [13420, 13180, 13050, 13210],
yoy: [12.5, 11.2, 10.8, 12.3]
},
{
name: 'ROE',
unit: '%',
values: [16.23, 15.98, 15.75, 16.02],
yoy: [1.2, 0.8, 0.5, 1.0]
},
{
name: 'EPS',
unit: '元',
values: [0.69, 0.68, 0.67, 0.68],
yoy: [12.3, 11.5, 10.5, 12.0]
}
]
}
};
};

View File

@@ -0,0 +1,554 @@
// src/mocks/data/industries.js
// 行业分类完整树形数据 Mock
/**
* 完整的行业分类树形结构
* 包含 5 个分类体系,层级深度 2-4 层不等
*/
export const industryTreeData = [
{
value: "新财富行业分类",
label: "新财富行业分类",
children: [
{
value: "XCF001",
label: "传播与文化",
children: [
{
value: "XCF001001",
label: "互联网传媒",
children: [
{ value: "XCF001001001", label: "数字媒体" },
{ value: "XCF001001002", label: "社交平台" },
{ value: "XCF001001003", label: "短视频平台" }
]
},
{
value: "XCF001002",
label: "影视娱乐",
children: [
{ value: "XCF001002001", label: "电影制作" },
{ value: "XCF001002002", label: "网络视频" }
]
},
{
value: "XCF001003",
label: "出版发行"
}
]
},
{
value: "XCF002",
label: "交通运输仓储",
children: [
{
value: "XCF002001",
label: "航空运输",
children: [
{ value: "XCF002001001", label: "航空客运" },
{ value: "XCF002001002", label: "航空货运" }
]
},
{
value: "XCF002002",
label: "铁路运输"
},
{
value: "XCF002003",
label: "公路运输",
children: [
{ value: "XCF002003001", label: "公路客运" },
{ value: "XCF002003002", label: "公路货运" },
{ value: "XCF002003003", label: "快递物流" }
]
}
]
},
{
value: "XCF003",
label: "农林牧渔",
children: [
{ value: "XCF003001", label: "种植业" },
{ value: "XCF003002", label: "林业" },
{ value: "XCF003003", label: "畜牧业" },
{ value: "XCF003004", label: "渔业" }
]
},
{
value: "XCF004",
label: "医药生物",
children: [
{
value: "XCF004001",
label: "化学制药",
children: [
{ value: "XCF004001001", label: "化学原料药" },
{ value: "XCF004001002", label: "化学制剂" }
]
},
{
value: "XCF004002",
label: "生物制品",
children: [
{ value: "XCF004002001", label: "疫苗" },
{ value: "XCF004002002", label: "血液制品" },
{ value: "XCF004002003", label: "诊断试剂" }
]
},
{ value: "XCF004003", label: "中药" },
{ value: "XCF004004", label: "医疗器械" }
]
},
{
value: "XCF005",
label: "基础化工",
children: [
{ value: "XCF005001", label: "化学原料" },
{ value: "XCF005002", label: "化学制品" },
{ value: "XCF005003", label: "塑料" },
{ value: "XCF005004", label: "橡胶" }
]
},
{
value: "XCF006",
label: "家电",
children: [
{ value: "XCF006001", label: "白色家电" },
{ value: "XCF006002", label: "黑色家电" },
{ value: "XCF006003", label: "小家电" }
]
},
{
value: "XCF007",
label: "电子",
children: [
{
value: "XCF007001",
label: "半导体",
children: [
{ value: "XCF007001001", label: "芯片设计" },
{ value: "XCF007001002", label: "芯片制造" },
{ value: "XCF007001003", label: "封装测试" }
]
},
{ value: "XCF007002", label: "元件" },
{ value: "XCF007003", label: "光学光电子" },
{ value: "XCF007004", label: "消费电子" }
]
},
{
value: "XCF008",
label: "计算机",
children: [
{
value: "XCF008001",
label: "计算机设备",
children: [
{ value: "XCF008001001", label: "PC" },
{ value: "XCF008001002", label: "服务器" }
]
},
{
value: "XCF008002",
label: "软件开发",
children: [
{ value: "XCF008002001", label: "应用软件" },
{ value: "XCF008002002", label: "系统软件" }
]
},
{ value: "XCF008003", label: "IT服务" }
]
}
]
},
{
value: "申银万国行业分类",
label: "申银万国行业分类",
children: [
{
value: "SW001",
label: "电子",
children: [
{
value: "SW001001",
label: "半导体",
children: [
{ value: "SW001001001", label: "半导体材料" },
{ value: "SW001001002", label: "半导体设备" },
{ value: "SW001001003", label: "集成电路" }
]
},
{
value: "SW001002",
label: "电子制造",
children: [
{ value: "SW001002001", label: "PCB" },
{ value: "SW001002002", label: "被动元件" }
]
},
{ value: "SW001003", label: "光学光电子" }
]
},
{
value: "SW002",
label: "计算机",
children: [
{ value: "SW002001", label: "计算机设备" },
{ value: "SW002002", label: "计算机应用" },
{ value: "SW002003", label: "通信设备" }
]
},
{
value: "SW003",
label: "传媒",
children: [
{ value: "SW003001", label: "互联网传媒" },
{ value: "SW003002", label: "营销传播" },
{ value: "SW003003", label: "文化传媒" }
]
},
{
value: "SW004",
label: "医药生物",
children: [
{ value: "SW004001", label: "化学制药" },
{ value: "SW004002", label: "中药" },
{ value: "SW004003", label: "生物制品" },
{ value: "SW004004", label: "医疗器械" },
{ value: "SW004005", label: "医药商业" }
]
},
{
value: "SW005",
label: "汽车",
children: [
{
value: "SW005001",
label: "乘用车",
children: [
{ value: "SW005001001", label: "燃油车" },
{ value: "SW005001002", label: "新能源车" }
]
},
{ value: "SW005002", label: "商用车" },
{ value: "SW005003", label: "汽车零部件" }
]
},
{
value: "SW006",
label: "机械设备",
children: [
{ value: "SW006001", label: "通用设备" },
{ value: "SW006002", label: "专用设备" },
{ value: "SW006003", label: "仪器仪表" }
]
},
{
value: "SW007",
label: "食品饮料",
children: [
{ value: "SW007001", label: "白酒" },
{ value: "SW007002", label: "啤酒" },
{ value: "SW007003", label: "软饮料" },
{ value: "SW007004", label: "食品加工" }
]
},
{
value: "SW008",
label: "银行",
children: [
{ value: "SW008001", label: "国有银行" },
{ value: "SW008002", label: "股份制银行" },
{ value: "SW008003", label: "城商行" }
]
},
{
value: "SW009",
label: "非银金融",
children: [
{ value: "SW009001", label: "证券" },
{ value: "SW009002", label: "保险" },
{ value: "SW009003", label: "多元金融" }
]
},
{
value: "SW010",
label: "房地产",
children: [
{ value: "SW010001", label: "房地产开发" },
{ value: "SW010002", label: "房地产服务" }
]
}
]
},
{
value: "证监会行业分类2001",
label: "证监会行业分类2001",
children: [
{
value: "CSRC_A",
label: "A 农、林、牧、渔业",
children: [
{ value: "CSRC_A01", label: "A01 农业" },
{ value: "CSRC_A02", label: "A02 林业" },
{ value: "CSRC_A03", label: "A03 畜牧业" },
{ value: "CSRC_A04", label: "A04 渔业" }
]
},
{
value: "CSRC_B",
label: "B 采矿业",
children: [
{ value: "CSRC_B06", label: "B06 煤炭开采和洗选业" },
{ value: "CSRC_B07", label: "B07 石油和天然气开采业" },
{ value: "CSRC_B08", label: "B08 黑色金属矿采选业" },
{ value: "CSRC_B09", label: "B09 有色金属矿采选业" }
]
},
{
value: "CSRC_C",
label: "C 制造业",
children: [
{
value: "CSRC_C13",
label: "C13 农副食品加工业",
children: [
{ value: "CSRC_C1310", label: "C1310 肉制品加工" },
{ value: "CSRC_C1320", label: "C1320 水产品加工" }
]
},
{
value: "CSRC_C27",
label: "C27 医药制造业",
children: [
{ value: "CSRC_C2710", label: "C2710 化学药品原料药制造" },
{ value: "CSRC_C2720", label: "C2720 化学药品制剂制造" },
{ value: "CSRC_C2730", label: "C2730 中药饮片加工" }
]
},
{ value: "CSRC_C35", label: "C35 专用设备制造业" },
{ value: "CSRC_C39", label: "C39 计算机、通信和其他电子设备制造业" }
]
},
{
value: "CSRC_I",
label: "I 信息传输、软件和信息技术服务业",
children: [
{ value: "CSRC_I63", label: "I63 电信、广播电视和卫星传输服务" },
{ value: "CSRC_I64", label: "I64 互联网和相关服务" },
{ value: "CSRC_I65", label: "I65 软件和信息技术服务业" }
]
},
{
value: "CSRC_J",
label: "J 金融业",
children: [
{ value: "CSRC_J66", label: "J66 货币金融服务" },
{ value: "CSRC_J67", label: "J67 资本市场服务" },
{ value: "CSRC_J68", label: "J68 保险业" }
]
},
{
value: "CSRC_K",
label: "K 房地产业",
children: [
{ value: "CSRC_K70", label: "K70 房地产业" }
]
}
]
},
{
value: "中银国际行业分类",
label: "中银国际行业分类",
children: [
{
value: "BOC001",
label: "能源",
children: [
{ value: "BOC001001", label: "石油天然气" },
{ value: "BOC001002", label: "煤炭" },
{ value: "BOC001003", label: "新能源" }
]
},
{
value: "BOC002",
label: "原材料",
children: [
{ value: "BOC002001", label: "化工" },
{ value: "BOC002002", label: "钢铁" },
{ value: "BOC002003", label: "有色金属" },
{ value: "BOC002004", label: "建材" }
]
},
{
value: "BOC003",
label: "工业",
children: [
{ value: "BOC003001", label: "机械" },
{ value: "BOC003002", label: "电气设备" },
{ value: "BOC003003", label: "国防军工" }
]
},
{
value: "BOC004",
label: "消费",
children: [
{
value: "BOC004001",
label: "可选消费",
children: [
{ value: "BOC004001001", label: "汽车" },
{ value: "BOC004001002", label: "家电" },
{ value: "BOC004001003", label: "纺织服装" }
]
},
{
value: "BOC004002",
label: "必需消费",
children: [
{ value: "BOC004002001", label: "食品饮料" },
{ value: "BOC004002002", label: "农林牧渔" }
]
}
]
},
{
value: "BOC005",
label: "医疗保健",
children: [
{ value: "BOC005001", label: "医药" },
{ value: "BOC005002", label: "医疗器械" },
{ value: "BOC005003", label: "医疗服务" }
]
},
{
value: "BOC006",
label: "金融",
children: [
{ value: "BOC006001", label: "银行" },
{ value: "BOC006002", label: "非银金融" }
]
},
{
value: "BOC007",
label: "科技",
children: [
{
value: "BOC007001",
label: "信息技术",
children: [
{ value: "BOC007001001", label: "半导体" },
{ value: "BOC007001002", label: "电子" },
{ value: "BOC007001003", label: "计算机" },
{ value: "BOC007001004", label: "通信" }
]
},
{ value: "BOC007002", label: "传媒" }
]
}
]
},
{
value: "巨潮行业分类",
label: "巨潮行业分类",
children: [
{
value: "JC01",
label: "制造业",
children: [
{
value: "JC0101",
label: "电气机械及器材制造业",
children: [
{ value: "JC010101", label: "电机制造" },
{ value: "JC010102", label: "输配电及控制设备制造" },
{ value: "JC010103", label: "电池制造" }
]
},
{
value: "JC0102",
label: "医药制造业",
children: [
{ value: "JC010201", label: "化学药品原药制造" },
{ value: "JC010202", label: "化学药品制剂制造" },
{ value: "JC010203", label: "中成药制造" },
{ value: "JC010204", label: "生物、生化制品制造" }
]
},
{ value: "JC0103", label: "食品制造业" },
{ value: "JC0104", label: "纺织业" }
]
},
{
value: "JC02",
label: "信息传输、软件和信息技术服务业",
children: [
{ value: "JC0201", label: "互联网和相关服务" },
{ value: "JC0202", label: "软件和信息技术服务业" }
]
},
{
value: "JC03",
label: "批发和零售业",
children: [
{ value: "JC0301", label: "批发业" },
{ value: "JC0302", label: "零售业" }
]
},
{
value: "JC04",
label: "房地产业",
children: [
{ value: "JC0401", label: "房地产开发经营" },
{ value: "JC0402", label: "物业管理" }
]
},
{
value: "JC05",
label: "金融业",
children: [
{ value: "JC0501", label: "货币金融服务" },
{ value: "JC0502", label: "资本市场服务" },
{ value: "JC0503", label: "保险业" }
]
},
{
value: "JC06",
label: "交通运输、仓储和邮政业",
children: [
{ value: "JC0601", label: "道路运输业" },
{ value: "JC0602", label: "航空运输业" },
{ value: "JC0603", label: "水上运输业" }
]
},
{
value: "JC07",
label: "采矿业",
children: [
{ value: "JC0701", label: "煤炭开采和洗选业" },
{ value: "JC0702", label: "石油和天然气开采业" },
{ value: "JC0703", label: "有色金属矿采选业" }
]
},
{
value: "JC08",
label: "农、林、牧、渔业",
children: [
{ value: "JC0801", label: "农业" },
{ value: "JC0802", label: "林业" },
{ value: "JC0803", label: "畜牧业" },
{ value: "JC0804", label: "渔业" }
]
},
{
value: "JC09",
label: "建筑业",
children: [
{ value: "JC0901", label: "房屋建筑业" },
{ value: "JC0902", label: "土木工程建筑业" },
{ value: "JC0903", label: "建筑装饰和其他建筑业" }
]
}
]
}
];

124
src/mocks/data/kline.js Normal file
View File

@@ -0,0 +1,124 @@
// src/mocks/data/kline.js
// K线数据生成函数
/**
* 生成分时数据 (timeline)
* 用于展示当日分钟级别的价格走势
*/
export const generateTimelineData = (indexCode) => {
const data = [];
const basePrice = getBasePrice(indexCode);
const today = new Date();
// 生成早盘数据 (09:30 - 11:30)
const morningStart = new Date(today.setHours(9, 30, 0, 0));
const morningEnd = new Date(today.setHours(11, 30, 0, 0));
generateTimeRange(data, morningStart, morningEnd, basePrice, 'morning');
// 生成午盘数据 (13:00 - 15:00)
const afternoonStart = new Date(today.setHours(13, 0, 0, 0));
const afternoonEnd = new Date(today.setHours(15, 0, 0, 0));
generateTimeRange(data, afternoonStart, afternoonEnd, basePrice, 'afternoon');
return data;
};
/**
* 生成日线数据 (daily)
* 用于获取历史收盘价等数据
*/
export const generateDailyData = (indexCode, days = 30) => {
const data = [];
const basePrice = getBasePrice(indexCode);
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// 跳过周末
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
const open = basePrice * (1 + (Math.random() * 0.04 - 0.02));
const close = open * (1 + (Math.random() * 0.03 - 0.015));
const high = Math.max(open, close) * (1 + Math.random() * 0.015);
const low = Math.min(open, close) * (1 - Math.random() * 0.015);
const volume = Math.floor(Math.random() * 50000000000 + 10000000000);
data.push({
date: formatDate(date),
time: formatDate(date),
open: parseFloat(open.toFixed(2)),
close: parseFloat(close.toFixed(2)),
high: parseFloat(high.toFixed(2)),
low: parseFloat(low.toFixed(2)),
volume: volume,
prev_close: i === 0 ? parseFloat((basePrice * 0.99).toFixed(2)) : data[data.length - 1]?.close
});
}
return data;
};
/**
* 生成时间范围内的数据
*/
function generateTimeRange(data, startTime, endTime, basePrice, session) {
const current = new Date(startTime);
let price = basePrice;
// 波动趋势(早盘和午盘可能有不同的走势)
const trend = session === 'morning' ? Math.random() * 0.02 - 0.01 : Math.random() * 0.015 - 0.005;
while (current <= endTime) {
// 添加随机波动
const volatility = (Math.random() - 0.5) * 0.005;
price = price * (1 + trend / 120 + volatility); // 每分钟微小变化
const volume = Math.floor(Math.random() * 500000000 + 100000000);
data.push({
time: formatTime(current),
price: parseFloat(price.toFixed(2)),
close: parseFloat(price.toFixed(2)),
volume: volume,
prev_close: basePrice
});
// 增加1分钟
current.setMinutes(current.getMinutes() + 1);
}
}
/**
* 获取不同指数的基准价格
*/
function getBasePrice(indexCode) {
const basePrices = {
'000001.SH': 3200, // 上证指数
'399001.SZ': 10500, // 深证成指
'399006.SZ': 2100 // 创业板指
};
return basePrices[indexCode] || 3000;
}
/**
* 格式化时间为 HH:mm
*/
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* 格式化日期为 YYYY-MM-DD
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

150
src/mocks/data/market.js Normal file
View File

@@ -0,0 +1,150 @@
// src/mocks/data/market.js
// 市场行情相关的 Mock 数据
// 生成市场数据
export const generateMarketData = (stockCode) => {
const basePrice = 13.50; // 基准价格平安银行约13.5元)
return {
stockCode,
// 成交数据 - 必须包含K线所需的字段
tradeData: {
success: true,
data: Array(30).fill(null).map((_, i) => {
const open = basePrice + (Math.random() - 0.5) * 0.5;
const close = basePrice + (Math.random() - 0.5) * 0.5;
const high = Math.max(open, close) + Math.random() * 0.3;
const low = Math.min(open, close) - Math.random() * 0.3;
return {
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
open: parseFloat(open.toFixed(2)),
close: parseFloat(close.toFixed(2)),
high: parseFloat(high.toFixed(2)),
low: parseFloat(low.toFixed(2)),
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
};
})
},
// 资金流向 - 融资融券数据数组
fundingData: {
success: true,
data: Array(30).fill(null).map((_, i) => ({
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
financing: {
balance: Math.floor(Math.random() * 5000000000) + 10000000000, // 融资余额
buy: Math.floor(Math.random() * 500000000) + 100000000, // 融资买入
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
},
securities: {
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
}
}))
},
// 大单统计 - 包含 daily_stats 数组
bigDealData: {
success: true,
data: [],
daily_stats: Array(10).fill(null).map((_, i) => ({
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
small_sell: Math.floor(Math.random() * 100000000) + 25000000
}))
},
// 异动分析 - 包含 grouped_data 数组
unusualData: {
success: true,
data: [],
grouped_data: Array(5).fill(null).map((_, i) => ({
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
events: [
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
],
count: 3
}))
},
// 股权质押
pledgeData: {
success: true,
data: {
total_pledged: 25.6, // 质押比例%
major_shareholders: [
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
],
update_date: '2024-09-30'
}
},
// 市场摘要
summaryData: {
success: true,
data: {
current_price: basePrice,
change: 0.25,
change_pct: 1.89,
open: 13.35,
high: 13.68,
low: 13.28,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96,
pb_ratio: 0.72,
total_market_cap: 262300000000,
circulating_market_cap: 262300000000
}
},
// 涨停分析
riseAnalysisData: {
success: true,
data: {
is_limit_up: false,
limit_up_price: basePrice * 1.10,
current_price: basePrice,
distance_to_limit: 8.92, // %
consecutive_days: 0,
reason: '',
concept_tags: ['银行', '深圳国资', 'MSCI', '沪深300']
}
},
// 最新分时数据
latestMinuteData: {
success: true,
data: Array(240).fill(null).map((_, i) => {
const minute = 9 * 60 + 30 + i; // 从9:30开始
const hour = Math.floor(minute / 60);
const min = minute % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
return {
time,
price: (basePrice + randomChange).toFixed(2),
volume: Math.floor(Math.random() * 2000000) + 500000,
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
};
}),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
trade_date: new Date().toISOString().split('T')[0],
type: 'minute'
}
};
};

View File

@@ -136,7 +136,9 @@ export const authHandlers = [
});
// 模拟微信授权 URL实际是微信的 URL
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=mock&redirect_uri=&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
// 使用真实的微信 AppID 和真实的授权回调地址(必须与微信开放平台配置的域名一致)
const mockRedirectUri = encodeURIComponent('http://valuefrontier.cn/api/auth/wechat/callback');
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=wxa8d74c47041b5f87&redirect_uri=${mockRedirectUri}&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
@@ -147,16 +149,16 @@ export const authHandlers = [
session.status = 'scanned';
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
// 再过2秒自动确认登录
// 再过5秒自动确认登录(延长时间让用户看到 scanned 状态)
setTimeout(() => {
const session2 = mockWechatSessions.get(sessionId);
if (session2 && session2.status === 'scanned') {
session2.status = 'confirmed';
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与后端保持一致
session2.user = {
id: 999,
nickname: '微信用户',
wechat_openid: 'mock_openid_' + sessionId,
avatar_url: 'https://i.pravatar.cc/150?img=99',
avatar_url: 'https://ui-avatars.com/api/?name=微信用户&size=150&background=4299e1&color=fff',
phone: null,
email: null,
has_wechat: true,
@@ -168,6 +170,7 @@ export const authHandlers = [
is_subscription_active: true,
subscription_days_left: 0
};
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
}
}, 2000);
@@ -185,7 +188,7 @@ export const authHandlers = [
}),
// 4. 检查微信扫码状态
http.post('/api/auth/wechat/check-status', async ({ request }) => {
http.post('/api/auth/wechat/check', async ({ request }) => {
await delay(200); // 轮询请求,延迟短一些
const body = await request.json();
@@ -209,18 +212,16 @@ export const authHandlers = [
console.log('[Mock] 检查微信状态:', { session_id, status: session.status });
// ✅ 返回与后端真实 API 一致的扁平化数据结构
return HttpResponse.json({
code: 0,
message: '成功',
data: {
status: session.status,
user: session.user
}
status: session.status,
user_info: session.user_info,
expires_in: Math.floor((session.createdAt + 5 * 60 * 1000 - Date.now()) / 1000)
});
}),
// 5. 微信登录确认
http.post('/api/auth/wechat/login', async ({ request }) => {
http.post('/api/auth/login/wechat', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
@@ -228,7 +229,7 @@ export const authHandlers = [
const session = mockWechatSessions.get(session_id);
if (!session || session.status !== 'confirmed') {
if (!session || session.status !== 'authorized') { // ✅ 使用 'authorized' 状态,与前端保持一致
return HttpResponse.json({
success: false,
error: '微信登录未确认或已过期'
@@ -386,12 +387,12 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
setTimeout(() => {
const session2 = mockWechatSessions.get(targetSessionId);
if (session2 && session2.status === 'scanned') {
session2.status = 'confirmed';
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
session2.user = {
id: 999,
nickname: '微信测试用户',
wechat_openid: 'mock_openid_' + targetSessionId,
avatar_url: 'https://i.pravatar.cc/150?img=99',
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
phone: null,
email: null,
has_wechat: true,
@@ -402,6 +403,7 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
is_subscription_active: true,
subscription_days_left: 0
};
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
}
}, 1000);

View File

@@ -0,0 +1,215 @@
// src/mocks/handlers/company.js
// 公司相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
import { PINGAN_BANK_DATA, generateCompanyData } from '../data/company';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 获取公司数据的辅助函数
const getCompanyData = (stockCode) => {
return stockCode === '000001' ? PINGAN_BANK_DATA : generateCompanyData(stockCode, '示例公司');
};
export const companyHandlers = [
// 1. 综合分析
http.get('/api/company/comprehensive-analysis/:stockCode', async ({ params }) => {
await delay(300);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.comprehensiveAnalysis
});
}),
// 2. 价值链分析
http.get('/api/company/value-chain-analysis/:stockCode', async ({ params }) => {
await delay(250);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.valueChainAnalysis
});
}),
// 3. 关键因素时间线
http.get('/api/company/key-factors-timeline/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
});
}),
// 4. 基本信息
http.get('/api/stock/:stockCode/basic-info', async ({ params }) => {
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.basicInfo
});
}),
// 5. 实际控制人
http.get('/api/stock/:stockCode/actual-control', async ({ params }) => {
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.actualControl
});
}),
// 6. 股权集中度
http.get('/api/stock/:stockCode/concentration', async ({ params }) => {
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.concentration
});
}),
// 7. 高管信息
http.get('/api/stock/:stockCode/management', async ({ params, request }) => {
await delay(200);
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 解析查询参数
const url = new URL(request.url);
const activeOnly = url.searchParams.get('active_only') === 'true';
let management = data.management || [];
// 如果需要只返回在职高管mock 数据中默认都是在职)
if (activeOnly) {
management = management.filter(m => m.status !== 'resigned');
}
return HttpResponse.json({
success: true,
data: management // 直接返回数组
});
}),
// 8. 十大流通股东
http.get('/api/stock/:stockCode/top-circulation-shareholders', async ({ params, request }) => {
await delay(200);
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 解析查询参数
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
const shareholders = (data.topCirculationShareholders || []).slice(0, limit);
return HttpResponse.json({
success: true,
data: shareholders // 直接返回数组
});
}),
// 9. 十大股东
http.get('/api/stock/:stockCode/top-shareholders', async ({ params, request }) => {
await delay(200);
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 解析查询参数
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
const shareholders = (data.topShareholders || []).slice(0, limit);
return HttpResponse.json({
success: true,
data: shareholders // 直接返回数组
});
}),
// 10. 分支机构
http.get('/api/stock/:stockCode/branches', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.branches || [] // 直接返回数组
});
}),
// 11. 公告列表
http.get('/api/stock/:stockCode/announcements', async ({ params, request }) => {
await delay(250);
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 解析查询参数
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const page = parseInt(url.searchParams.get('page') || '1', 10);
const type = url.searchParams.get('type');
let announcements = data.announcements || [];
// 类型筛选
if (type) {
announcements = announcements.filter(a => a.type === type);
}
// 分页
const start = (page - 1) * limit;
const end = start + limit;
const paginatedAnnouncements = announcements.slice(start, end);
return HttpResponse.json({
success: true,
data: paginatedAnnouncements // 直接返回数组
});
}),
// 12. 披露时间表
http.get('/api/stock/:stockCode/disclosure-schedule', async ({ params }) => {
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.disclosureSchedule || [] // 直接返回数组
});
}),
// 13. 盈利预测报告
http.get('/api/stock/:stockCode/forecast-report', async ({ params }) => {
await delay(300);
const { stockCode } = params;
const data = getCompanyData(stockCode);
return HttpResponse.json({
success: true,
data: data.forecastReport || null
});
}),
];

View File

@@ -0,0 +1,159 @@
// src/mocks/handlers/concept.js
// 概念相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 生成热门概念数据
const generatePopularConcepts = (size = 20) => {
const concepts = [
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
'储能', '氢能源', '风电', '特高压', '工业母机',
'军工', '航空航天', '卫星导航', '量子科技', '数字货币',
'云计算', '大数据', '物联网', '5G', '6G',
'元宇宙', '虚拟现实', 'AIGC', 'ChatGPT', '算力',
'芯片设计', '芯片制造', '半导体设备', '半导体材料', 'EDA',
'新能源', '风光储', '充电桩', '智能电网', '特斯拉',
'比亚迪', '宁德时代', '华为', '苹果产业链', '鸿蒙',
'国产软件', '信创', '网络安全', '数据安全', '量子通信',
'医疗器械', '创新药', '医美', 'CXO', '生物医药',
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
];
const results = [];
for (let i = 0; i < Math.min(size, concepts.length); i++) {
const changePct = (Math.random() * 12 - 2).toFixed(2); // -2% 到 +10%
const stockCount = Math.floor(Math.random() * 50) + 10; // 10-60 只股票
results.push({
concept: concepts[i],
concept_id: `CONCEPT_${1000 + i}`,
stock_count: stockCount,
price_info: {
avg_change_pct: parseFloat(changePct),
avg_price: (Math.random() * 100 + 10).toFixed(2),
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
},
description: `${concepts[i]}相关概念股`,
hot_score: Math.floor(Math.random() * 100)
});
}
// 按涨跌幅降序排序
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
return results;
};
// 概念相关的 Handlers
export const conceptHandlers = [
// 搜索概念(热门概念)
http.post('/concept-api/search', async ({ request }) => {
await delay(300);
try {
const body = await request.json();
const { query = '', size = 20, page = 1, sort_by = 'change_pct' } = body;
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
// 生成数据
let results = generatePopularConcepts(size);
// 如果有查询关键词,过滤结果
if (query) {
results = results.filter(item =>
item.concept.toLowerCase().includes(query.toLowerCase())
);
}
// 根据排序字段排序
if (sort_by === 'change_pct') {
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
} else if (sort_by === 'stock_count') {
results.sort((a, b) => b.stock_count - a.stock_count);
} else if (sort_by === 'hot_score') {
results.sort((a, b) => b.hot_score - a.hot_score);
}
return HttpResponse.json({
results,
total: results.length,
page,
size,
message: '搜索成功'
});
} catch (error) {
console.error('[Mock Concept] 搜索概念失败:', error);
return HttpResponse.json(
{
results: [],
total: 0,
error: '搜索失败'
},
{ status: 500 }
);
}
}),
// 获取单个概念详情
http.get('/concept-api/concepts/:conceptId', async ({ params }) => {
await delay(300);
const { conceptId } = params;
console.log('[Mock Concept] 获取概念详情:', conceptId);
const concepts = generatePopularConcepts(50);
const concept = concepts.find(c => c.concept_id === conceptId || c.concept === conceptId);
if (concept) {
return HttpResponse.json({
...concept,
related_stocks: [
{ stock_code: '600519', stock_name: '贵州茅台', change_pct: 2.34 },
{ stock_code: '000858', stock_name: '五粮液', change_pct: 1.89 },
{ stock_code: '000568', stock_name: '泸州老窖', change_pct: 3.12 }
],
news: [
{ title: `${concept.concept}板块异动`, date: '2024-10-24', source: '财经新闻' }
]
});
} else {
return HttpResponse.json(
{ error: '概念不存在' },
{ status: 404 }
);
}
}),
// 获取概念相关股票
http.get('/concept-api/concepts/:conceptId/stocks', async ({ params, request }) => {
await delay(300);
const { conceptId } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
console.log('[Mock Concept] 获取概念相关股票:', { conceptId, limit });
// 生成模拟股票数据
const stocks = [];
for (let i = 0; i < limit; i++) {
stocks.push({
stock_code: `${600000 + i}`,
stock_name: `股票${i + 1}`,
change_pct: (Math.random() * 10 - 2).toFixed(2),
price: (Math.random() * 100 + 10).toFixed(2),
market_cap: (Math.random() * 1000 + 100).toFixed(2)
});
}
return HttpResponse.json({
stocks,
total: stocks.length,
concept_id: conceptId
});
})
];

View File

@@ -2,13 +2,117 @@
// 事件相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks } from '../data/events';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const eventHandlers = [
// ==================== 事件列表相关 ====================
// 获取事件列表
http.get('/api/events/', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const params = {
page: parseInt(url.searchParams.get('page') || '1'),
per_page: parseInt(url.searchParams.get('per_page') || '10'),
sort: url.searchParams.get('sort') || 'new',
importance: url.searchParams.get('importance') || 'all',
date_range: url.searchParams.get('date_range') || '',
q: url.searchParams.get('q') || '',
industry_code: url.searchParams.get('industry_code') || '',
industry_classification: url.searchParams.get('industry_classification') || '',
stock_code: url.searchParams.get('stock_code') || '',
};
console.log('[Mock] 获取事件列表:', params);
try {
const result = generateMockEvents(params);
return HttpResponse.json({
success: true,
data: result,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件列表失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件列表失败',
data: { events: [], pagination: {} }
},
{ status: 500 }
);
}
}),
// 获取热点事件
http.get('/api/events/hot', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '5');
console.log('[Mock] 获取热点事件, limit:', limit);
try {
const hotEvents = generateHotEvents(limit);
return HttpResponse.json({
success: true,
data: hotEvents,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热点事件失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热点事件失败',
data: []
},
{ status: 500 }
);
}
}),
// 获取热门关键词
http.get('/api/events/keywords/popular', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
console.log('[Mock] 获取热门关键词, limit:', limit);
try {
const keywords = generatePopularKeywords(limit);
return HttpResponse.json({
success: true,
data: keywords,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热门关键词失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热门关键词失败',
data: []
},
{ status: 500 }
);
}
}),
// ==================== 事件详情相关 ====================
// 获取事件相关股票
http.get('/api/events/:eventId/stocks', async ({ params }) => {
await delay(300);
@@ -317,7 +421,8 @@ export const eventHandlers = [
transmission_mechanism: {
data: [
{
author: "中国电子信息产业发展研究院",
author: "李明",
organization: "中国电子信息产业发展研究院",
sentences: "在技术突破和应用场景快速扩张的双重驱动下国内半导体市场呈现爆发式增长态势。据统计2024年上半年半导体市场规模达到1.2万亿元同比增长32%其中新能源汽车和AI算力芯片需求贡献了超过60%的增量",
query_part: "技术突破和需求激增推动半导体市场增长32%",
match_score: "好",
@@ -336,7 +441,8 @@ export const eventHandlers = [
transmission_mechanism: {
data: [
{
author: "经济观察报",
author: "张华",
organization: "经济观察报",
sentences: "随着半导体等高科技产业获得大量政策和资金支持传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低",
query_part: "资源向高科技倾斜导致传统制造业承压",
match_score: "好",
@@ -355,7 +461,8 @@ export const eventHandlers = [
transmission_mechanism: {
data: [
{
author: "能源研究所",
author: "王刚",
organization: "能源研究所",
sentences: "半导体产业扩张带来电力需求增长约8%但同时推动节能技术应用整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷但智能电网技术应用使能源利用效率提升12%",
query_part: "半导体产业对能源行业影响相对中性",
match_score: "中",
@@ -374,7 +481,8 @@ export const eventHandlers = [
transmission_mechanism: {
data: [
{
author: "教育部职业教育司",
author: "赵敏",
organization: "教育部职业教育司",
sentences: "半导体产业快速发展催生大量专业人才需求各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人带动职业教育市场规模扩大",
query_part: "半导体产业推动职业教育发展",
match_score: "好",
@@ -409,7 +517,8 @@ export const eventHandlers = [
transmission_mechanism: {
data: [
{
author: "中国半导体行业协会",
author: "刘洋",
organization: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",
query_part: "新兴应用推动半导体需求增长28%",
match_score: "好",
@@ -417,7 +526,8 @@ export const eventHandlers = [
report_title: "2024年Q1中国半导体行业景气度报告"
},
{
author: "中国半导体行业协会",
author: "刘洋",
organization: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",
query_part: "新兴应用推动半导体需求增长28%",
match_score: "好",
@@ -458,6 +568,7 @@ export const eventHandlers = [
data: [
{
author: "张明",
organization: "中信证券",
sentences: "在半导体行业景气度持续提升的背景下下游芯片制造企业订单饱满产能利用率达到历史新高预计2024年产能扩张将达到30%以上技术工艺也将从28nm向14nm升级",
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
match_score: "好",
@@ -466,6 +577,7 @@ export const eventHandlers = [
},
{
author: "李华",
organization: "海通证券",
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
query_part: "技术迭代加快,先进制程占比提升",
match_score: "好",
@@ -531,6 +643,7 @@ export const eventHandlers = [
data: [
{
author: "王芳",
organization: "国泰君安",
sentences: "A公司作为国内芯片制造龙头企业在手订单已排至2024年Q4预计全年营收增长45%净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
query_part: "A公司在手订单充足预计营收增长45%",
match_score: "好",
@@ -550,6 +663,7 @@ export const eventHandlers = [
data: [
{
author: "赵强",
organization: "华泰证券",
sentences: "随着芯片制造产能的大规模扩张上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业订单量同比增长55%产品供不应求预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位",
query_part: "B公司订单增长55%,营收将破百亿",
match_score: "好",
@@ -557,7 +671,8 @@ export const eventHandlers = [
report_title: "B公司跟踪报告芯片产业链配套龙头崛起"
},
{
author: "国信证券",
author: "陈彤",
organization: "国信证券",
sentences: "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局成功绑定多家头部芯片制造企业形成稳定的供应关系。随着下游客户产能持续扩张公司业绩增长确定性强",
query_part: "B公司受益产业链景气度业绩增长确定性强",
match_score: "好",
@@ -633,6 +748,7 @@ export const eventHandlers = [
data: [
{
author: "赵强",
organization: "华泰证券",
sentences: "随着芯片制造产能的大规模扩张上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业订单量同比增长55%产品供不应求预计2024年营收将突破百亿大关",
query_part: "B公司订单增长55%,营收将破百亿",
match_score: "好",

View File

@@ -0,0 +1,121 @@
// src/mocks/handlers/financial.js
// 财务数据相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
import { generateFinancialData } from '../data/financial';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
export const financialHandlers = [
// 1. 股票基本信息
http.get('/api/financial/stock-info/:stockCode', async ({ params }) => {
await delay(150);
const { stockCode } = params;
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.stockInfo
});
}),
// 2. 资产负债表
http.get('/api/financial/balance-sheet/:stockCode', async ({ params, request }) => {
await delay(250);
const { stockCode } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.balanceSheet.slice(0, limit)
});
}),
// 3. 利润表
http.get('/api/financial/income-statement/:stockCode', async ({ params, request }) => {
await delay(250);
const { stockCode } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.incomeStatement.slice(0, limit)
});
}),
// 4. 现金流量表
http.get('/api/financial/cashflow/:stockCode', async ({ params, request }) => {
await delay(250);
const { stockCode } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.cashflow.slice(0, limit)
});
}),
// 5. 财务指标
http.get('/api/financial/financial-metrics/:stockCode', async ({ params, request }) => {
await delay(250);
const { stockCode } = params;
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.financialMetrics.slice(0, limit)
});
}),
// 6. 主营业务
http.get('/api/financial/main-business/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.mainBusiness
});
}),
// 7. 业绩预告
http.get('/api/financial/forecast/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.forecast
});
}),
// 8. 行业排名
http.get('/api/financial/industry-rank/:stockCode', async ({ params }) => {
await delay(250);
const { stockCode } = params;
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.industryRank
});
}),
// 9. 期间对比
http.get('/api/financial/comparison/:stockCode', async ({ params }) => {
await delay(250);
const { stockCode } = params;
const data = generateFinancialData(stockCode);
return HttpResponse.json({
success: true,
data: data.periodComparison
});
}),
];

View File

@@ -6,6 +6,12 @@ import { accountHandlers } from './account';
import { simulationHandlers } from './simulation';
import { eventHandlers } from './event';
import { paymentHandlers } from './payment';
import { industryHandlers } from './industry';
import { conceptHandlers } from './concept';
import { stockHandlers } from './stock';
import { companyHandlers } from './company';
import { marketHandlers } from './market';
import { financialHandlers } from './financial';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -16,5 +22,11 @@ export const handlers = [
...simulationHandlers,
...eventHandlers,
...paymentHandlers,
...industryHandlers,
...conceptHandlers,
...stockHandlers,
...companyHandlers,
...marketHandlers,
...financialHandlers,
// ...userHandlers,
];

View File

@@ -0,0 +1,44 @@
// src/mocks/handlers/industry.js
// 行业分类相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { industryData } from '../../data/industryData';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const industryHandlers = [
// 获取行业分类完整树形结构
http.get('/api/classifications', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const classification = url.searchParams.get('classification');
console.log('[Mock] 获取行业分类树形数据(真实数据)', { classification });
try {
let data = industryData;
// 如果指定了分类体系,只返回该体系的数据
if (classification) {
data = industryData.filter(item => item.value === classification);
}
return HttpResponse.json({
success: true,
data: data
});
} catch (error) {
console.error('[Mock] 获取行业分类失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取行业分类失败',
data: []
},
{ status: 500 }
);
}
})
];

View File

@@ -0,0 +1,74 @@
// src/mocks/handlers/market.js
// 市场行情相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
import { generateMarketData } from '../data/market';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
export const marketHandlers = [
// 1. 成交数据
http.get('/api/market/trade/:stockCode', async ({ params, request }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.tradeData);
}),
// 2. 资金流向
http.get('/api/market/funding/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.fundingData);
}),
// 3. 大单统计
http.get('/api/market/bigdeal/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.bigDealData);
}),
// 4. 异动分析
http.get('/api/market/unusual/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.unusualData);
}),
// 5. 股权质押
http.get('/api/market/pledge/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.pledgeData);
}),
// 6. 市场摘要
http.get('/api/market/summary/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.summaryData);
}),
// 7. 涨停分析
http.get('/api/market/rise-analysis/:stockCode', async ({ params }) => {
await delay(200);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.riseAnalysisData);
}),
// 8. 最新分时数据
http.get('/api/stock/:stockCode/latest-minute', async ({ params }) => {
await delay(300);
const { stockCode } = params;
const data = generateMarketData(stockCode);
return HttpResponse.json(data.latestMinuteData);
}),
];

227
src/mocks/handlers/stock.js Normal file
View File

@@ -0,0 +1,227 @@
// src/mocks/handlers/stock.js
// 股票相关的 Mock Handlers
import { http, HttpResponse } from 'msw';
import { generateTimelineData, generateDailyData } from '../data/kline';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 生成A股主要股票数据包含各大指数成分股
const generateStockList = () => {
const stocks = [
// 银行
{ code: '000001', name: '平安银行' },
{ code: '600000', name: '浦发银行' },
{ code: '600036', name: '招商银行' },
{ code: '601166', name: '兴业银行' },
{ code: '601169', name: '北京银行' },
{ code: '601288', name: '农业银行' },
{ code: '601328', name: '交通银行' },
{ code: '601398', name: '工商银行' },
{ code: '601818', name: '光大银行' },
{ code: '601939', name: '建设银行' },
{ code: '601998', name: '中信银行' },
// 证券
{ code: '600030', name: '中信证券' },
{ code: '600109', name: '国金证券' },
{ code: '600837', name: '海通证券' },
{ code: '600999', name: '招商证券' },
{ code: '601688', name: '华泰证券' },
{ code: '601901', name: '方正证券' },
// 保险
{ code: '601318', name: '中国平安' },
{ code: '601336', name: '新华保险' },
{ code: '601601', name: '中国太保' },
{ code: '601628', name: '中国人寿' },
// 白酒/食品饮料
{ code: '000568', name: '泸州老窖' },
{ code: '000596', name: '古井贡酒' },
{ code: '000858', name: '五粮液' },
{ code: '600519', name: '贵州茅台' },
{ code: '600600', name: '青岛啤酒' },
{ code: '600779', name: '水井坊' },
{ code: '603369', name: '今世缘' },
// 医药
{ code: '000538', name: '云南白药' },
{ code: '000661', name: '长春高新' },
{ code: '002422', name: '科伦药业' },
{ code: '002594', name: '比亚迪' },
{ code: '600276', name: '恒瑞医药' },
{ code: '600436', name: '片仔癀' },
{ code: '603259', name: '药明康德' },
// 科技/半导体
{ code: '000063', name: '中兴通讯' },
{ code: '000725', name: '京东方A' },
{ code: '002049', name: '紫光国微' },
{ code: '002415', name: '海康威视' },
{ code: '002475', name: '立讯精密' },
{ code: '600584', name: '长电科技' },
{ code: '600893', name: '航发动力' },
{ code: '603501', name: '韦尔股份' },
// 新能源/电力
{ code: '000002', name: '万科A' },
{ code: '002460', name: '赣锋锂业' },
{ code: '300750', name: '宁德时代' },
{ code: '600438', name: '通威股份' },
{ code: '601012', name: '隆基绿能' },
{ code: '601668', name: '中国建筑' },
// 汽车
{ code: '000625', name: '长安汽车' },
{ code: '600066', name: '宇通客车' },
{ code: '600104', name: '上汽集团' },
{ code: '601238', name: '广汽集团' },
{ code: '601633', name: '长城汽车' },
// 地产
{ code: '000002', name: '万科A' },
{ code: '000069', name: '华侨城A' },
{ code: '600340', name: '华夏幸福' },
{ code: '600606', name: '绿地控股' },
// 家电
{ code: '000333', name: '美的集团' },
{ code: '000651', name: '格力电器' },
{ code: '002032', name: '苏泊尔' },
{ code: '600690', name: '海尔智家' },
// 互联网/电商
{ code: '002024', name: '苏宁易购' },
{ code: '002074', name: '国轩高科' },
{ code: '300059', name: '东方财富' },
// 能源/化工
{ code: '600028', name: '中国石化' },
{ code: '600309', name: '万华化学' },
{ code: '600547', name: '山东黄金' },
{ code: '600585', name: '海螺水泥' },
{ code: '601088', name: '中国神华' },
{ code: '601857', name: '中国石油' },
// 电信/运营商
{ code: '600050', name: '中国联通' },
{ code: '600941', name: '中国移动' },
{ code: '601728', name: '中国电信' },
// 其他蓝筹
{ code: '600887', name: '伊利股份' },
{ code: '601111', name: '中国国航' },
{ code: '601390', name: '中国中铁' },
{ code: '601899', name: '紫金矿业' },
{ code: '603288', name: '海天味业' },
];
return stocks;
};
// 股票相关的 Handlers
export const stockHandlers = [
// 获取所有股票列表
http.get('/api/stocklist', async () => {
await delay(200);
try {
const stocks = generateStockList();
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
return HttpResponse.json(stocks);
} catch (error) {
console.error('[Mock Stock] 获取股票列表失败:', error);
return HttpResponse.json(
{ error: '获取股票列表失败' },
{ status: 500 }
);
}
}),
// 获取指数K线数据
http.get('/api/index/:indexCode/kline', async ({ params, request }) => {
await delay(300);
const { indexCode } = params;
const url = new URL(request.url);
const type = url.searchParams.get('type') || 'timeline';
const eventTime = url.searchParams.get('event_time');
console.log('[Mock Stock] 获取指数K线数据:', { indexCode, type, eventTime });
try {
let data;
if (type === 'timeline') {
data = generateTimelineData(indexCode);
} else if (type === 'daily') {
data = generateDailyData(indexCode, 30);
} else {
return HttpResponse.json(
{ error: '不支持的类型' },
{ status: 400 }
);
}
return HttpResponse.json({
success: true,
data: data,
index_code: indexCode,
type: type,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取K线数据失败:', error);
return HttpResponse.json(
{ error: '获取K线数据失败' },
{ status: 500 }
);
}
}),
// 获取股票K线数据
http.get('/api/stock/:stockCode/kline', async ({ params, request }) => {
await delay(300);
const { stockCode } = params;
const url = new URL(request.url);
const type = url.searchParams.get('type') || 'timeline';
const eventTime = url.searchParams.get('event_time');
console.log('[Mock Stock] 获取股票K线数据:', { stockCode, type, eventTime });
try {
let data;
if (type === 'timeline') {
// 股票使用指数的数据生成逻辑,但价格基数不同
data = generateTimelineData('000001.SH'); // 可以根据股票代码调整
} else if (type === 'daily') {
data = generateDailyData('000001.SH', 30);
} else {
return HttpResponse.json(
{ error: '不支持的类型' },
{ status: 400 }
);
}
return HttpResponse.json({
success: true,
data: data,
stock_code: stockCode,
type: type,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取股票K线数据失败:', error);
return HttpResponse.json(
{ error: '获取K线数据失败' },
{ status: 500 }
);
}
}),
];

View File

@@ -144,19 +144,23 @@ export const WECHAT_STATUS = {
WAITING: 'waiting',
SCANNED: 'scanned',
AUTHORIZED: 'authorized',
LOGIN_SUCCESS: 'login_success',
REGISTER_SUCCESS: 'register_success',
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
EXPIRED: 'expired',
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
AUTH_FAILED: 'auth_failed', // 授权失败
};
/**
* 状态提示信息映射
*/
export const STATUS_MESSAGES = {
[WECHAT_STATUS.WAITING]: '使用微信扫',
[WECHAT_STATUS.WAITING]: '使用微信扫一扫登陆',
[WECHAT_STATUS.SCANNED]: '扫码成功,请在手机上确认',
[WECHAT_STATUS.AUTHORIZED]: '授权成功,正在登录...',
[WECHAT_STATUS.EXPIRED]: '二维码已过期',
[WECHAT_STATUS.AUTH_DENIED]: '用户取消授权',
[WECHAT_STATUS.AUTH_FAILED]: '授权失败,请重试',
};
export default authService;

View File

@@ -5,25 +5,25 @@ import axios from 'axios';
// 判断当前是否是生产环境
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
// 配置 axios 默认包含 credentials
axios.defaults.withCredentials = true;
export const industryService = {
// 获取所有行业分类体系
async getClassifications() {
const res = await axios.get(`${API_BASE_URL}/api/classifications`);
return res.data;
},
// 获取指定体系下的多级行业
async getLevels({ classification, level = 1, level1_name, level2_name, level3_name }) {
let url = `${API_BASE_URL}/api/levels?classification=${encodeURIComponent(classification)}&level=${level}`;
if (level1_name) url += `&level1_name=${encodeURIComponent(level1_name)}`;
if (level2_name) url += `&level2_name=${encodeURIComponent(level2_name)}`;
if (level3_name) url += `&level3_name=${encodeURIComponent(level3_name)}`;
/**
* 获取行业分类完整树形结构
* @param {string} classification - 可选,指定分类体系名称,不传则返回所有
* @returns {Promise} 返回树形结构数据
*/
async getClassifications(classification) {
let url = `${API_BASE_URL}/api/classifications`;
if (classification) {
url += `?classification=${encodeURIComponent(classification)}`;
}
const res = await axios.get(url);
return res.data;
}
// 注意getLevels 接口已废弃,使用 getClassifications 替代
};

View File

@@ -303,6 +303,7 @@ const mockFinancialNews = [
class MockSocketService {
constructor() {
this.connected = false;
this.connecting = false; // 新增:正在连接标志,防止重复连接
this.listeners = new Map();
this.intervals = [];
this.messageQueue = [];
@@ -325,18 +326,30 @@ class MockSocketService {
* 连接到 mock socket
*/
connect() {
// ✅ 防止重复连接
if (this.connected) {
logger.warn('mockSocketService', 'Already connected');
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
return;
}
if (this.connecting) {
logger.warn('mockSocketService', 'Connection in progress');
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
return;
}
this.connecting = true; // 标记为连接中
logger.info('mockSocketService', 'Connecting to mock socket service...');
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
// 模拟连接延迟
setTimeout(() => {
// 检查是否应该模拟连接失败
if (this.failConnection) {
this.connecting = false; // 清除连接中标志
logger.warn('mockSocketService', 'Simulated connection failure');
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
// 触发连接错误事件
this.emit('connect_error', {
@@ -351,6 +364,7 @@ class MockSocketService {
// 正常连接成功
this.connected = true;
this.connecting = false; // 清除连接中标志
this.reconnectAttempts = 0;
// 清除自定义重连定时器
@@ -360,9 +374,15 @@ class MockSocketService {
}
logger.info('mockSocketService', 'Mock socket connected successfully');
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
// 触发连接成功事件
this.emit('connect', { timestamp: Date.now() });
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
setTimeout(() => {
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
this.emit('connect', { timestamp: Date.now() });
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
}, 0);
// 在连接后3秒发送欢迎消息
setTimeout(() => {

View File

@@ -25,4 +25,73 @@ console.log(
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
);
// ========== 暴露调试 API 到全局 ==========
if (typeof window !== 'undefined') {
// 暴露 Socket 类型到全局
window.SOCKET_TYPE = SOCKET_TYPE;
// 暴露调试 API
window.__SOCKET_DEBUG__ = {
// 获取当前连接状态
getStatus: () => {
const isConnected = socket.connected || false;
return {
type: SOCKET_TYPE,
connected: isConnected,
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
service: useMock ? 'mockSocketService' : 'socketService',
};
},
// 手动重连
reconnect: () => {
console.log('[Socket Debug] Manual reconnect triggered');
if (socket.reconnect) {
socket.reconnect();
} else {
socket.disconnect();
socket.connect();
}
},
// 断开连接
disconnect: () => {
console.log('[Socket Debug] Manual disconnect triggered');
socket.disconnect();
},
// 连接
connect: () => {
console.log('[Socket Debug] Manual connect triggered');
socket.connect();
},
// 获取服务实例 (仅用于调试)
getService: () => socket,
// 导出诊断信息
exportDiagnostics: () => {
const status = window.__SOCKET_DEBUG__.getStatus();
const diagnostics = {
...status,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
};
console.log('[Socket Diagnostics]', diagnostics);
return diagnostics;
}
};
console.log(
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
'color: #2196F3; font-weight: bold;'
);
console.log(
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
'color: #2196F3;'
);
}
export default socket;

View File

@@ -0,0 +1,101 @@
// src/services/stockService.js
// 股票数据服务
import { logger } from '../utils/logger';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
/**
* 股票数据服务
*/
export const stockService = {
/**
* 获取所有股票列表
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
*/
async getAllStocks() {
try {
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
logger.debug('stockService', 'getAllStocks 成功', {
count: data?.length || 0
});
return {
success: true,
data: data || []
};
} catch (error) {
logger.error('stockService', 'getAllStocks', error);
return {
success: false,
data: [],
error: error.message
};
}
},
/**
* 模糊搜索股票(匹配 code 或 name
* @param {string} query - 搜索关键词
* @param {Array<{code: string, name: string}>} stockList - 股票列表
* @param {number} limit - 返回结果数量限制
* @returns {Array<{code: string, name: string}>}
*/
fuzzySearch(query, stockList, limit = 10) {
if (!query || !stockList || stockList.length === 0) {
return [];
}
const lowerQuery = query.toLowerCase();
// 模糊匹配 code 或 name
const results = stockList.filter(stock => {
const code = (stock.code || '').toString().toLowerCase();
const name = (stock.name || '').toLowerCase();
return code.includes(lowerQuery) || name.includes(lowerQuery);
});
// 优先级排序:
// 1. code 精确匹配
// 2. name 精确匹配
// 3. code 开头匹配
// 4. name 开头匹配
// 5. 其他包含匹配
results.sort((a, b) => {
const aCode = (a.code || '').toString().toLowerCase();
const aName = (a.name || '').toLowerCase();
const bCode = (b.code || '').toString().toLowerCase();
const bName = (b.name || '').toLowerCase();
// 精确匹配
if (aCode === lowerQuery) return -1;
if (bCode === lowerQuery) return 1;
if (aName === lowerQuery) return -1;
if (bName === lowerQuery) return 1;
// 开头匹配
if (aCode.startsWith(lowerQuery) && !bCode.startsWith(lowerQuery)) return -1;
if (!aCode.startsWith(lowerQuery) && bCode.startsWith(lowerQuery)) return 1;
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1;
if (!aName.startsWith(lowerQuery) && bName.startsWith(lowerQuery)) return 1;
// 字母顺序
return aCode.localeCompare(bCode);
});
return results.slice(0, limit);
}
};

18
src/store/index.js Normal file
View File

@@ -0,0 +1,18 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice';
export const store = configureStore({
reducer: {
communityData: communityDataReducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// 忽略这些 action types 的序列化检查
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
},
}),
});
export default store;

View File

@@ -0,0 +1,274 @@
// src/store/slices/communityDataSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
// ==================== 常量定义 ====================
// 缓存键名
const CACHE_KEYS = {
POPULAR_KEYWORDS: 'community_popular_keywords',
HOT_EVENTS: 'community_hot_events'
};
// 请求去重:缓存正在进行的请求
const pendingRequests = new Map();
// ==================== 通用数据获取逻辑 ====================
/**
* 通用的数据获取函数(支持三级缓存 + 请求去重)
* @param {Object} options - 配置选项
* @param {string} options.cacheKey - 缓存键名
* @param {Function} options.fetchFn - API 获取函数
* @param {Function} options.getState - Redux getState 函数
* @param {string} options.stateKey - Redux state 中的键名
* @param {boolean} options.forceRefresh - 是否强制刷新
* @returns {Promise<any>} 获取的数据
*/
const fetchWithCache = async ({
cacheKey,
fetchFn,
getState,
stateKey,
forceRefresh = false
}) => {
// 请求去重:如果有正在进行的相同请求,直接返回该 Promise
if (!forceRefresh && pendingRequests.has(cacheKey)) {
logger.debug('CommunityData', `复用进行中的请求: ${stateKey}`);
return pendingRequests.get(cacheKey);
}
const requestPromise = (async () => {
try {
// 第一级缓存:检查 Redux 状态(除非强制刷新)
if (!forceRefresh) {
const stateData = getState().communityData[stateKey];
if (stateData && stateData.length > 0) {
logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`);
return stateData;
}
// 第二级缓存:检查 localStorage
const cachedData = localCacheManager.get(cacheKey);
if (cachedData) {
return cachedData;
}
}
// 第三级:从 API 获取
logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh });
const response = await fetchFn();
if (response.success && response.data) {
// 保存到 localStorage午夜过期
localCacheManager.set(cacheKey, response.data, CACHE_EXPIRY_STRATEGY.MIDNIGHT);
return response.data;
}
logger.warn('CommunityData', `API 返回数据为空:${stateKey}`);
return [];
} catch (error) {
logger.error('CommunityData', `获取${stateKey}失败`, error);
throw error;
} finally {
// 请求完成后清除缓存
pendingRequests.delete(cacheKey);
}
})();
// 缓存请求 Promise
if (!forceRefresh) {
pendingRequests.set(cacheKey, requestPromise);
}
return requestPromise;
};
// ==================== Reducer 工厂函数 ====================
/**
* 创建通用的 reducer cases
* @param {Object} builder - Redux Toolkit builder
* @param {Object} asyncThunk - createAsyncThunk 返回的对象
* @param {string} dataKey - state 中的数据键名(如 'popularKeywords'
*/
const createDataReducers = (builder, asyncThunk, dataKey) => {
builder
.addCase(asyncThunk.pending, (state) => {
state.loading[dataKey] = true;
state.error[dataKey] = null;
})
.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;
state.error[dataKey] = action.payload;
logger.error('CommunityData', `${dataKey} 加载失败`, new Error(action.payload));
});
};
// ==================== Async Thunks ====================
/**
* 获取热门关键词
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchPopularKeywords = createAsyncThunk(
'communityData/fetchPopularKeywords',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.POPULAR_KEYWORDS,
fetchFn: () => eventService.getPopularKeywords(20),
getState,
stateKey: 'popularKeywords',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热门关键词失败');
}
}
);
/**
* 获取热点事件
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
*/
export const fetchHotEvents = createAsyncThunk(
'communityData/fetchHotEvents',
async (forceRefresh = false, { getState, rejectWithValue }) => {
try {
return await fetchWithCache({
cacheKey: CACHE_KEYS.HOT_EVENTS,
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 20 }),
getState,
stateKey: 'hotEvents',
forceRefresh
});
} catch (error) {
return rejectWithValue(error.message || '获取热点事件失败');
}
}
);
// ==================== Slice 定义 ====================
const communityDataSlice = createSlice({
name: 'communityData',
initialState: {
// 数据
popularKeywords: [],
hotEvents: [],
// 加载状态
loading: {
popularKeywords: false,
hotEvents: false
},
// 错误信息
error: {
popularKeywords: null,
hotEvents: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
*/
clearCache: (state) => {
// 清除 localStorage
localCacheManager.removeMultiple(Object.values(CACHE_KEYS));
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
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', '热点事件缓存已清除');
}
},
/**
* 预加载数据(用于应用启动时)
* 注意:这不是异步 action只是触发标记
*/
preloadData: (state) => {
logger.info('CommunityData', '准备预加载数据');
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
}
},
extraReducers: (builder) => {
// 使用工厂函数创建 reducers消除重复代码
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
createDataReducers(builder, fetchHotEvents, 'hotEvents');
}
});
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
// 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
// 组合选择器
export const selectPopularKeywordsWithLoading = (state) => ({
data: state.communityData.popularKeywords,
loading: state.communityData.loading.popularKeywords,
error: state.communityData.error.popularKeywords,
lastUpdated: state.communityData.lastUpdated.popularKeywords
});
export const selectHotEventsWithLoading = (state) => ({
data: state.communityData.hotEvents,
loading: state.communityData.loading.hotEvents,
error: state.communityData.error.hotEvents,
lastUpdated: state.communityData.lastUpdated.hotEvents
});
// 工具函数:检查数据是否需要刷新(超过指定时间)
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
if (!lastUpdated) return true;
const elapsed = Date.now() - new Date(lastUpdated).getTime();
return elapsed > thresholdMinutes * 60 * 1000;
};
export default communityDataSlice.reducer;

313
src/utils/CacheManager.js Normal file
View File

@@ -0,0 +1,313 @@
// src/utils/CacheManager.js
import { logger } from './logger';
/**
* 缓存过期策略
*/
export const CACHE_EXPIRY_STRATEGY = {
MIDNIGHT: 'midnight', // 当天午夜过期
HOURS: 'hours', // 指定小时后过期
NEVER: 'never' // 永不过期
};
/**
* 缓存管理器类
* 提供统一的缓存操作接口,支持多种过期策略
*/
class CacheManager {
constructor(storage = localStorage, logContext = 'CacheManager') {
this.storage = storage;
this.logContext = logContext;
}
/**
* 计算过期时间
* @param {string} strategy - 过期策略
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
* @returns {string|null} ISO 格式的过期时间,或 null永不过期
*/
_calculateExpireTime(strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
if (strategy === CACHE_EXPIRY_STRATEGY.NEVER) {
return null;
}
const expireDate = new Date();
if (strategy === CACHE_EXPIRY_STRATEGY.MIDNIGHT) {
// 设置为明天凌晨 0 点
expireDate.setDate(expireDate.getDate() + 1);
expireDate.setHours(0, 0, 0, 0);
} else if (strategy === CACHE_EXPIRY_STRATEGY.HOURS) {
// 设置为指定小时后
expireDate.setHours(expireDate.getHours() + hours);
}
return expireDate.toISOString();
}
/**
* 检查缓存是否过期
* @param {string|null} expireAt - 过期时间ISO 格式)
* @returns {boolean} 是否过期
*/
_isExpired(expireAt) {
if (!expireAt) return false; // null 表示永不过期
return new Date() > new Date(expireAt);
}
/**
* 获取缓存数据
* @param {string} key - 缓存键名
* @returns {any|null} 缓存的数据,如果不存在或已过期返回 null
*/
get(key) {
try {
const cached = this.storage.getItem(key);
if (!cached) return null;
const { data, expireAt, cachedAt } = JSON.parse(cached);
// 检查是否过期
if (this._isExpired(expireAt)) {
this.remove(key);
logger.debug(this.logContext, '缓存已过期', { key, expireAt });
return null;
}
logger.debug(this.logContext, '使用缓存数据', {
key,
dataLength: Array.isArray(data) ? data.length : typeof data,
expireAt,
cachedAt
});
return data;
} catch (error) {
logger.error(this.logContext, 'get 缓存失败', error, { key });
// 清除损坏的缓存
this.remove(key);
return null;
}
}
/**
* 设置缓存数据
* @param {string} key - 缓存键名
* @param {any} data - 要缓存的数据
* @param {string} strategy - 过期策略
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
* @returns {boolean} 是否设置成功
*/
set(key, data, strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
try {
const cacheData = {
data,
expireAt: this._calculateExpireTime(strategy, hours),
cachedAt: new Date().toISOString(),
strategy
};
this.storage.setItem(key, JSON.stringify(cacheData));
logger.debug(this.logContext, '数据已缓存', {
key,
dataLength: Array.isArray(data) ? data.length : typeof data,
expireAt: cacheData.expireAt,
strategy
});
return true;
} catch (error) {
logger.error(this.logContext, 'set 缓存失败', error, { key });
// 处理 localStorage 配额已满
if (error.name === 'QuotaExceededError') {
logger.warn(this.logContext, 'Storage 配额已满,尝试清理部分缓存');
this._handleQuotaExceeded();
// 清理后重试一次
try {
this.storage.setItem(key, JSON.stringify({
data,
expireAt: this._calculateExpireTime(strategy, hours),
cachedAt: new Date().toISOString(),
strategy
}));
return true;
} catch (retryError) {
logger.error(this.logContext, '重试 set 缓存仍失败', retryError, { key });
return false;
}
}
return false;
}
}
/**
* 删除指定缓存
* @param {string} key - 缓存键名
*/
remove(key) {
try {
this.storage.removeItem(key);
logger.debug(this.logContext, '缓存已删除', { key });
} catch (error) {
logger.error(this.logContext, 'remove 缓存失败', error, { key });
}
}
/**
* 批量删除缓存
* @param {string[]} keys - 缓存键名数组
*/
removeMultiple(keys) {
keys.forEach(key => this.remove(key));
logger.info(this.logContext, '批量删除缓存完成', { count: keys.length });
}
/**
* 清除所有缓存
*/
clear() {
try {
this.storage.clear();
logger.info(this.logContext, '所有缓存已清除');
} catch (error) {
logger.error(this.logContext, 'clear 缓存失败', error);
}
}
/**
* 检查缓存是否存在且有效
* @param {string} key - 缓存键名
* @returns {boolean} 是否存在有效缓存
*/
has(key) {
return this.get(key) !== null;
}
/**
* 获取缓存元数据(不包含数据本身)
* @param {string} key - 缓存键名
* @returns {Object|null} 元数据对象 { expireAt, cachedAt, strategy }
*/
getMetadata(key) {
try {
const cached = this.storage.getItem(key);
if (!cached) return null;
const { expireAt, cachedAt, strategy } = JSON.parse(cached);
return { expireAt, cachedAt, strategy, isExpired: this._isExpired(expireAt) };
} catch (error) {
logger.error(this.logContext, 'getMetadata 失败', error, { key });
return null;
}
}
/**
* 处理存储配额已满的情况
* 优先删除最旧的或已过期的缓存
* @private
*/
_handleQuotaExceeded() {
try {
const cacheItems = [];
// 收集所有缓存项
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (!key) continue;
try {
const cached = this.storage.getItem(key);
const { cachedAt, expireAt } = JSON.parse(cached);
cacheItems.push({
key,
cachedAt: new Date(cachedAt),
expireAt: expireAt ? new Date(expireAt) : null,
isExpired: this._isExpired(expireAt)
});
} catch (e) {
// 解析失败的项直接删除
this.storage.removeItem(key);
}
}
// 按优先级排序:已过期 > 最旧的
cacheItems.sort((a, b) => {
if (a.isExpired && !b.isExpired) return -1;
if (!a.isExpired && b.isExpired) return 1;
return a.cachedAt - b.cachedAt;
});
// 删除前 20% 的缓存
const deleteCount = Math.max(1, Math.floor(cacheItems.length * 0.2));
for (let i = 0; i < deleteCount; i++) {
this.storage.removeItem(cacheItems[i].key);
}
logger.info(this.logContext, `已清理 ${deleteCount} 个缓存项`);
} catch (error) {
logger.error(this.logContext, '_handleQuotaExceeded 失败', error);
// 最后手段:清除所有缓存
this.clear();
}
}
/**
* 获取所有缓存键名
* @returns {string[]} 键名数组
*/
keys() {
const keys = [];
try {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key) keys.push(key);
}
} catch (error) {
logger.error(this.logContext, 'keys 获取失败', error);
}
return keys;
}
/**
* 获取存储使用情况(仅支持 localStorage/sessionStorage
* @returns {Object} { used, total, percentage }
*/
getStorageInfo() {
try {
let used = 0;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key) {
used += (key.length + (this.storage.getItem(key)?.length || 0)) * 2; // UTF-16
}
}
// 大多数浏览器 localStorage 限制为 5-10MB
const total = 5 * 1024 * 1024; // 5MB
const percentage = (used / total * 100).toFixed(2);
return {
used,
total,
percentage: parseFloat(percentage),
usedMB: (used / 1024 / 1024).toFixed(2),
totalMB: (total / 1024 / 1024).toFixed(2)
};
} catch (error) {
logger.error(this.logContext, 'getStorageInfo 失败', error);
return null;
}
}
}
// 导出单例实例(使用 localStorage
export const localCacheManager = new CacheManager(localStorage, 'LocalCache');
// 导出单例实例(使用 sessionStorage
export const sessionCacheManager = new CacheManager(sessionStorage, 'SessionCache');
// 导出类本身,供自定义实例化
export default CacheManager;

View File

@@ -82,6 +82,7 @@ export const processCitationData = (rawData) => {
// 构建引用信息映射
citations[citationId] = {
author: item.author || '未知作者',
organization: item.organization || '', // 券商名/机构名,可选字段
report_title: item.report_title || '未知报告',
declare_date: formatDate(item.declare_date),
sentences: item.sentences,

View File

@@ -3,6 +3,43 @@
const isDevelopment = process.env.NODE_ENV === 'development';
// ========== 日志限流配置 ==========
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
const recentLogs = new Map(); // 日志缓存,用于去重
const MAX_CACHE_SIZE = 100; // 最大缓存数量
/**
* 生成日志的唯一键
*/
function getLogKey(component, message) {
return `${component}:${message}`;
}
/**
* 检查是否应该输出日志(限流检查)
*/
function shouldLog(component, message) {
const key = getLogKey(component, message);
const now = Date.now();
const lastLog = recentLogs.get(key);
// 如果1秒内已经输出过相同日志,跳过
if (lastLog && now - lastLog < LOG_THROTTLE_TIME) {
return false;
}
// 记录日志时间
recentLogs.set(key, now);
// 限制缓存大小,避免内存泄漏
if (recentLogs.size > MAX_CACHE_SIZE) {
const oldestKey = recentLogs.keys().next().value;
recentLogs.delete(oldestKey);
}
return true;
}
/**
* 统一日志工具
* 开发环境:输出详细日志
@@ -20,7 +57,7 @@ export const logger = {
* @param {object} data - 请求参数/body
*/
request: (method, url, data = null) => {
if (isDevelopment) {
if (isDevelopment && shouldLog('API', `${method} ${url}`)) {
console.group(`🌐 API Request: ${method} ${url}`);
console.log('Timestamp:', new Date().toISOString());
if (data) console.log('Data:', data);
@@ -36,7 +73,7 @@ export const logger = {
* @param {any} data - 响应数据
*/
response: (method, url, status, data) => {
if (isDevelopment) {
if (isDevelopment && shouldLog('API', `${method} ${url} ${status}`)) {
console.group(`✅ API Response: ${method} ${url}`);
console.log('Status:', status);
console.log('Data:', data);
@@ -53,6 +90,7 @@ export const logger = {
* @param {object} requestData - 请求参数(可选)
*/
error: (method, url, error, requestData = null) => {
// API 错误始终输出,不做限流
console.group(`❌ API Error: ${method} ${url}`);
console.error('Error:', error);
console.error('Message:', error?.message || error);
@@ -75,6 +113,7 @@ export const logger = {
* @param {object} context - 上下文信息(可选)
*/
error: (component, method, error, context = {}) => {
// 错误日志始终输出,不做限流
console.group(`🔴 Error in ${component}.${method}`);
console.error('Error:', error);
console.error('Message:', error?.message || error);
@@ -93,7 +132,7 @@ export const logger = {
* @param {object} data - 相关数据(可选)
*/
warn: (component, message, data = {}) => {
if (isDevelopment) {
if (isDevelopment && shouldLog(component, message)) {
console.group(`⚠️ Warning: ${component}`);
console.warn('Message:', message);
if (Object.keys(data).length > 0) {
@@ -111,7 +150,7 @@ export const logger = {
* @param {object} data - 相关数据(可选)
*/
debug: (component, message, data = {}) => {
if (isDevelopment) {
if (isDevelopment && shouldLog(component, message)) {
console.group(`🐛 Debug: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {
@@ -129,7 +168,7 @@ export const logger = {
* @param {object} data - 相关数据(可选)
*/
info: (component, message, data = {}) => {
if (isDevelopment) {
if (isDevelopment && shouldLog(component, message)) {
console.group(` Info: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {

View File

@@ -1,256 +0,0 @@
// src/views/Community/components/EventFilters.js
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, DatePicker, Button, Select, Form, Input } from 'antd';
import { FilterOutlined } from '@ant-design/icons';
import moment from 'moment';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { industryService } from '../../../services/industryService';
import { logger } from '../../../utils/logger';
const { RangePicker } = DatePicker;
const { Option } = Select;
const EventFilters = ({ filters, onFilterChange, loading }) => {
const [form] = Form.useForm();
const [industryData, setIndustryData] = useState({
classifications: [],
level1: [],
level2: [],
level3: [],
level4: []
});
// 初始化表单值
useEffect(() => {
const initialValues = {
date_range: filters.date_range ? filters.date_range.split(' 至 ').map(d => moment(d)) : null,
sort: filters.sort,
importance: filters.importance,
industry_classification: filters.industry_classification,
industry_code: filters.industry_code
};
form.setFieldsValue(initialValues);
}, [filters, form]);
// 加载行业分类数据
const loadIndustryClassifications = async () => {
try {
const response = await industryService.getClassifications();
setIndustryData(prev => ({ ...prev, classifications: response.data }));
logger.debug('EventFilters', '行业分类加载成功', {
count: response.data?.length || 0
});
} catch (error) {
logger.error('EventFilters', 'loadIndustryClassifications', error);
}
};
// 加载行业层级数据
const loadIndustryLevels = async (level, params) => {
try {
const response = await industryService.getLevels(params);
setIndustryData(prev => ({ ...prev, [`level${level}`]: response.data }));
// 清空下级
for (let l = level + 1; l <= 4; l++) {
setIndustryData(prev => ({ ...prev, [`level${l}`]: [] }));
}
logger.debug('EventFilters', '行业层级数据加载成功', {
level,
count: response.data?.length || 0
});
} catch (error) {
logger.error('EventFilters', 'loadIndustryLevels', error, { level, params });
}
};
useEffect(() => {
loadIndustryClassifications();
}, []);
const handleDateRangeChange = (dates) => {
if (dates && dates.length === 2) {
const dateRange = `${dates[0].format('YYYY-MM-DD')}${dates[1].format('YYYY-MM-DD')}`;
onFilterChange('date_range', dateRange);
} else {
onFilterChange('date_range', '');
}
};
const handleSortChange = (value) => {
onFilterChange('sort', value);
};
const handleImportanceChange = (value) => {
onFilterChange('importance', value);
};
// 行业分类体系变化时,加载一级行业
const handleIndustryClassificationChange = (value) => {
form.setFieldsValue({ industry_code: '' });
onFilterChange('industry_classification', value);
setIndustryData(prev => ({ ...prev, level1: [], level2: [], level3: [], level4: [] }));
if (value) {
loadIndustryLevels(1, { classification: value, level: 1 });
}
};
// 级联选择行业
const handleLevelChange = (level, value) => {
// 直接从state里查找name
let name = '';
if (level === 1) {
const found = industryData.level1.find(item => item.code === value);
name = found ? found.name : '';
} else if (level === 2) {
const found = industryData.level2.find(item => item.code === value);
name = found ? found.name : '';
} else if (level === 3) {
const found = industryData.level3.find(item => item.code === value);
name = found ? found.name : '';
} else if (level === 4) {
const found = industryData.level4.find(item => item.code === value);
name = found ? found.name : '';
}
form.setFieldsValue({ [`level${level}`]: value });
form.setFieldsValue({ industry_code: value });
onFilterChange('industry_code', value);
for (let l = level + 1; l <= 4; l++) {
form.setFieldsValue({ [`level${l}`]: undefined });
}
const params = { classification: form.getFieldValue('industry_classification'), level: level + 1 };
if (level === 1) params.level1_name = name;
if (level === 2) {
params.level1_name = form.getFieldValue('level1_name');
params.level2_name = name;
}
if (level === 3) {
params.level1_name = form.getFieldValue('level1_name');
params.level2_name = form.getFieldValue('level2_name');
params.level3_name = name;
}
if (level < 4 && name) {
loadIndustryLevels(level + 1, params);
}
form.setFieldsValue({ [`level${level}_name`]: name });
};
return (
<Card className="event-filters" title="事件筛选" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="日期范围" name="date_range">
<RangePicker
style={{ width: '100%' }}
locale={locale}
placeholder={['开始日期', '结束日期']}
onChange={handleDateRangeChange}
disabled={loading}
allowEmpty={[true, true]}
/>
</Form.Item>
</Col>
<Col span={12}>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="排序方式" name="sort">
<Select onChange={handleSortChange} disabled={loading}>
<Option value="new">最新</Option>
<Option value="hot">热门</Option>
<Option value="returns">收益率</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="重要性" name="importance">
<Select onChange={handleImportanceChange} disabled={loading}>
<Option value="all">全部</Option>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
</Select>
</Form.Item>
</Col>
</Row>
</Col>
</Row>
<Row gutter={16}>
<Col span={6}>
<Form.Item label="行业分类" name="industry_classification">
<Select
placeholder="选择行业分类体系"
onChange={handleIndustryClassificationChange}
disabled={loading}
allowClear
>
{industryData.classifications.map(item => (
<Option key={item.name} value={item.name}>{item.name}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label="一级行业" name="level1">
<Select
placeholder="选择一级行业"
onChange={value => handleLevelChange(1, value)}
disabled={loading || !form.getFieldValue('industry_classification')}
allowClear
>
{industryData.level1.map(item => (
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label="二级行业" name="level2">
<Select
placeholder="选择二级行业"
onChange={value => handleLevelChange(2, value)}
disabled={loading || !form.getFieldValue('level1')}
allowClear
>
{industryData.level2.map(item => (
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label="三级行业" name="level3">
<Select
placeholder="选择三级行业"
onChange={value => handleLevelChange(3, value)}
disabled={loading || !form.getFieldValue('level2')}
allowClear
>
{industryData.level3.map(item => (
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={4}>
<Form.Item label="四级行业" name="level4">
<Select
placeholder="选择四级行业"
onChange={value => handleLevelChange(4, value)}
disabled={loading || !form.getFieldValue('level3')}
allowClear
>
{industryData.level4.map(item => (
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
);
};
export default EventFilters;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,63 @@
// src/views/Community/components/EventModals.js
// 事件弹窗组合组件包含详情Modal和股票Drawer
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton
} from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import StockDetailPanel from './StockDetailPanel';
/**
* 事件弹窗组合组件
* @param {Object} eventModalState - 事件详情Modal状态
* @param {boolean} eventModalState.isOpen - 是否打开
* @param {Function} eventModalState.onClose - 关闭回调
* @param {Object} eventModalState.event - 事件对象
* @param {Function} eventModalState.onEventClose - 事件关闭回调(清除状态)
* @param {Object} stockDrawerState - 股票详情Drawer状态
* @param {boolean} stockDrawerState.visible - 是否显示
* @param {Object} stockDrawerState.event - 事件对象
* @param {Function} stockDrawerState.onClose - 关闭回调
*/
const EventModals = ({
eventModalState,
stockDrawerState
}) => {
return (
<>
{/* 事件详情模态框 - 使用Chakra UI Modal */}
<Modal
isOpen={eventModalState.isOpen}
onClose={eventModalState.onClose}
size="xl"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>事件详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<EventDetailModal
event={eventModalState.event}
onClose={eventModalState.onEventClose}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer */}
<StockDetailPanel
visible={stockDrawerState.visible}
event={stockDrawerState.event}
onClose={stockDrawerState.onClose}
/>
</>
);
};
export default EventModals;

View File

@@ -0,0 +1,83 @@
// src/views/Community/components/EventTimelineCard.js
// 事件时间轴卡片组件整合Header + Search + List
import React, { forwardRef } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
useColorModeValue
} from '@chakra-ui/react';
import EventTimelineHeader from './EventTimelineHeader';
import UnifiedSearchBox from './UnifiedSearchBox';
import EventListSection from './EventListSection';
/**
* 事件时间轴卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Object} pagination - 分页信息
* @param {Object} filters - 筛选条件
* @param {Array} popularKeywords - 热门关键词
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onSearch - 搜索回调
* @param {Function} onSearchFocus - 搜索框获得焦点回调
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} ref - 用于滚动的ref
*/
const EventTimelineCard = forwardRef(({
events,
loading,
pagination,
filters,
popularKeywords,
lastUpdateTime,
onSearch,
onSearchFocus,
onPageChange,
onEventClick,
onViewDetail,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* 统一搜索组件(整合了话题、股票、行业、日期、排序、重要性、热门概念、筛选标签) */}
<Box mb={4}>
<UnifiedSearchBox
onSearch={onSearch}
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
/>
</Box>
{/* 事件列表包含Loading、Empty、List三种状态 */}
<EventListSection
loading={loading}
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</CardBody>
</Card>
);
});
EventTimelineCard.displayName = 'EventTimelineCard';
export default EventTimelineCard;

View File

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

View File

@@ -1,8 +1,7 @@
/* Hot Events Section */
.hot-events-section {
padding: 24px 0;
padding-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
@@ -17,11 +16,76 @@
margin-bottom: 24px;
}
/* Carousel */
.carousel-wrapper {
position: relative;
}
.carousel-counter {
position: absolute;
top: 8px; /* 容器内部顶部 */
right: 48px; /* 避开右侧箭头 */
z-index: 100; /* 确保在卡片和箭头上方 */
background: rgba(24, 144, 255, 0.95);
color: white;
font-size: 13px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
pointer-events: none; /* 不阻挡鼠标事件 */
}
.hot-events-carousel {
padding: 0 40px; /* 增加左右padding为箭头留出空间 */
position: relative;
}
.hot-events-carousel .carousel-item {
padding: 0 8px;
}
/* 自定义箭头样式 */
.custom-carousel-arrow {
width: 40px !important;
height: 40px !important;
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 50% !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
transition: all 0.3s ease !important;
z-index: 10 !important;
}
.custom-carousel-arrow:hover {
background: rgba(255, 255, 255, 1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
}
.custom-carousel-arrow:hover .anticon {
color: #096dd9 !important;
}
/* 箭头位置 */
.hot-events-carousel .slick-prev.custom-carousel-arrow {
left: 0 !important;
}
.hot-events-carousel .slick-next.custom-carousel-arrow {
right: 0 !important;
}
/* 禁用状态 */
.custom-carousel-arrow.slick-disabled {
opacity: 0.3 !important;
cursor: not-allowed !important;
}
/* Card */
.hot-event-card {
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
margin: 0 auto;
}
.hot-event-card:hover {
@@ -29,11 +93,16 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
/* Cover image */
/* Card body padding */
.hot-event-card .ant-card-body {
padding: 12px;
}
/* Cover image - 高度减半 */
.event-cover {
position: relative;
width: 100%;
height: 160px;
height: 80px;
overflow: hidden;
}
@@ -55,28 +124,53 @@
/* Card content */
.event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.event-header .ant-tag {
margin-right: 6px;
}
.event-title {
font-size: 16px;
font-weight: 600;
color: #000;
flex: 1;
margin-bottom: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
/* 标题文字 - inline显示可以换行 */
.event-title {
cursor: pointer;
}
/* 标签紧跟标题后面 */
.event-tag {
display: inline;
margin-left: 4px;
white-space: nowrap;
vertical-align: baseline;
}
.event-tag .ant-tag {
font-size: 11px;
padding: 0 6px;
height: 18px;
line-height: 18px;
transform: scale(0.9);
vertical-align: middle;
}
/* 详情描述 - 三行省略 */
.event-description {
margin: 8px 0;
font-size: 14px;
color: #595959;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 4.5em;
cursor: pointer;
}
.event-footer {
@@ -84,6 +178,7 @@
justify-content: space-between;
font-size: 12px;
color: #8c8c8c;
margin-top: 8px;
}
.creator {
@@ -93,6 +188,19 @@
max-width: 60%;
}
/* 时间样式 - 年月日高亮 */
.time {
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.time-date {
color: #1890ff;
font-weight: 600;
}
.time-hour {
color: #8c8c8c;
}

View File

@@ -1,13 +1,34 @@
// src/views/Community/components/HotEvents.js
import React from 'react';
import { Card, Row, Col, Badge, Tag, Empty } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, FireOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg'
const HotEvents = ({ events }) => {
// 自定义箭头组件
const CustomArrow = ({ className, style, onClick, direction }) => {
const Icon = direction === 'left' ? LeftOutlined : RightOutlined;
return (
<div
className={`${className} custom-carousel-arrow`}
style={{
...style,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={onClick}
>
<Icon style={{ fontSize: '20px', color: '#1890ff' }} />
</div>
);
};
const HotEvents = ({ events, onPageChange }) => {
const navigate = useNavigate();
const [currentSlide, setCurrentSlide] = useState(0);
const renderPriceChange = (value) => {
if (value === null || value === undefined) {
@@ -39,18 +60,60 @@ const HotEvents = ({ events }) => {
navigate(`/event-detail/${eventId}`);
};
// 计算总页数
const totalPages = Math.ceil((events?.length || 0) / 4);
// Carousel 配置
const carouselSettings = {
dots: false, // 隐藏圆点导航
infinite: true, // 始终启用无限循环,确保箭头显示
speed: 500,
slidesToShow: 4,
slidesToScroll: 1,
arrows: true, // 保留左右箭头
prevArrow: <CustomArrow direction="left" />,
nextArrow: <CustomArrow direction="right" />,
autoplay: false,
beforeChange: (_current, next) => {
// 计算实际页码(考虑无限循环)
const actualPage = next % totalPages;
setCurrentSlide(actualPage);
// 通知父组件页码变化
if (onPageChange) {
onPageChange(actualPage + 1, totalPages);
}
},
responsive: [
{
breakpoint: 1200,
settings: {
slidesToShow: 3,
slidesToScroll: 1,
}
},
{
breakpoint: 992,
settings: {
slidesToShow: 2,
slidesToScroll: 1,
}
},
{
breakpoint: 576,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
}
}
]
};
return (
<div className="hot-events-section">
<h2 className="section-title">
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
近期热点信息
</h2>
<p className="section-subtitle">展示最近5天内涨幅最高的事件助您把握市场热点</p>
{events && events.length > 0 ? (
<Row gutter={[16, 16]}>
<Carousel {...carouselSettings} className="hot-events-carousel">
{events.map((event, index) => (
<Col lg={6} md={12} sm={24} key={event.id}>
<div key={event.id} className="carousel-item">
<Card
hoverable
className="hot-event-card"
@@ -75,33 +138,36 @@ const HotEvents = ({ events }) => {
</div>
}
>
<Card.Meta
title={
<div className="event-header">
{renderPriceChange(event.related_avg_chg)}
<span className="event-title">
{event.title}
</span>
</div>
}
description={
<>
<p className="event-description">
{event.description && event.description.length > 80
? `${event.description.substring(0, 80)}...`
: event.description}
</p>
<div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
<span className="time">{moment(event.created_at).format('MM-DD HH:mm')}</span>
</div>
</>
}
/>
{/* Custom layout without Card.Meta */}
<div className="event-header">
<Tooltip title={event.title}>
<span className="event-title">
{event.title}
</span>
</Tooltip>
<span className="event-tag">
{renderPriceChange(event.related_avg_chg)}
</span>
</div>
<Tooltip title={event.description}>
<div className="event-description">
{event.description}
</div>
</Tooltip>
<div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
<span className="time">
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
{' '}
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
</span>
</div>
</Card>
</Col>
</div>
))}
</Row>
</Carousel>
) : (
<Card>
<Empty description="暂无热点信息" />

View File

@@ -0,0 +1,64 @@
// src/views/Community/components/HotEventsSection.js
// 热点事件区域组件
import React, { useState } from 'react';
import {
Card,
CardHeader,
CardBody,
Heading,
Badge,
Box,
useColorModeValue
} from '@chakra-ui/react';
import HotEvents from './HotEvents';
/**
* 热点事件区域组件
* @param {Array} events - 热点事件列表
*/
const HotEventsSection = ({ events }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// 处理页码变化
const handlePageChange = (page, total) => {
setCurrentPage(page);
setTotalPages(total);
};
// 如果没有热点事件,不渲染组件
if (!events || events.length === 0) {
return null;
}
return (
<Card mt={0} bg={cardBg}>
<CardHeader pb={0} display="flex" justifyContent="space-between" alignItems="flex-start">
<Box>
<Heading size="md">🔥 热点事件</Heading>
<p className="section-subtitle" style={{paddingTop: '8px'}}>展示最近5天内涨幅最高的事件助您把握市场热点</p>
</Box>
{/* 页码指示器 */}
{totalPages > 1 && (
<Badge
colorScheme="blue"
fontSize="sm"
px={3}
py={1}
borderRadius="full"
ml={4}
>
{currentPage} / {totalPages}
</Badge>
)}
</CardHeader>
<CardBody py={0} px={4}>
<HotEvents events={events} onPageChange={handlePageChange} />
</CardBody>
</Card>
);
};
export default HotEventsSection;

View File

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

View File

@@ -1,5 +1,5 @@
// src/views/Community/components/InvestmentCalendar.js
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
Drawer, Typography, Divider, Space, Tooltip, message, Alert
@@ -48,7 +48,7 @@ const InvestmentCalendar = () => {
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
// 加载月度事件统计
const loadEventCounts = async (date) => {
const loadEventCounts = useCallback(async (date) => {
try {
const year = date.year();
const month = date.month() + 1;
@@ -63,7 +63,7 @@ const InvestmentCalendar = () => {
month: date.month() + 1
});
}
};
}, []); // eventService 是外部导入的稳定引用,不需要作为依赖
// 加载指定日期的事件
const loadDateEvents = async (date) => {
@@ -131,7 +131,7 @@ const InvestmentCalendar = () => {
useEffect(() => {
loadEventCounts(currentMonth);
}, [currentMonth]);
}, [currentMonth, loadEventCounts]);
// 自定义日期单元格渲染
const dateCellRender = (value) => {
@@ -700,6 +700,7 @@ const InvestmentCalendar = () => {
width={1200}
footer={null}
bodyStyle={{ padding: '24px' }}
zIndex={1500}
>
<Spin spinning={loading}>
<Tabs defaultActiveKey="event">
@@ -734,6 +735,7 @@ const InvestmentCalendar = () => {
width={600}
onClose={() => setDetailDrawerVisible(false)}
visible={detailDrawerVisible}
zIndex={1500}
>
{selectedDetail?.content?.type === 'citation' ? (
<CitedContent
@@ -770,6 +772,7 @@ const InvestmentCalendar = () => {
关闭
</Button>
}
zIndex={1500}
>
{hasFeatureAccess('related_stocks') ? (
<Table

View File

@@ -480,9 +480,10 @@ export default function MidjourneyHeroSection() {
minH="100vh"
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
overflow="hidden"
pointerEvents="none"
>
{/* 粒子背景 */}
<Box position="absolute" inset={0} zIndex={0}>
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
<Particles
id="tsparticles"
init={particlesInit}
@@ -499,7 +500,7 @@ export default function MidjourneyHeroSection() {
<DataStreams />
{/* 内容容器 */}
<Container maxW="7xl" position="relative" zIndex={20} pt={20} pb={20}>
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
{/* 左侧文本内容 */}
@@ -776,7 +777,7 @@ export default function MidjourneyHeroSection() {
borderRadius="full"
filter="blur(40px)"
animation="pulse 4s ease-in-out infinite"
animationDelay="2s"
sx={{ animationDelay: '2s' }}
/>
</Box>
</Box>
@@ -793,16 +794,16 @@ export default function MidjourneyHeroSection() {
right={0}
h="128px"
bgGradient="linear(to-t, black, transparent)"
zIndex={10}
zIndex={-1}
/>
{/* 全局样式 */}
<style jsx global>{`
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }

View File

@@ -1,25 +1,24 @@
// src/views/Community/components/PopularKeywords.js
import React, { useState, useEffect } from 'react';
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
import { FireOutlined, RightOutlined } from '@ant-design/icons';
import { Tag, Space, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { RightOutlined } from '@ant-design/icons';
import { logger } from '../../../utils/logger';
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/concept-api'
: 'http://192.168.1.58:6801';
// 使用相对路径,让 MSW 在开发环境可以拦截请求
const API_BASE_URL = '/concept-api';
// 获取域名前缀
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
? ''
: 'https://valuefrontier.cn';
const PopularKeywords = ({ onKeywordClick }) => {
const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
const [keywords, setKeywords] = useState([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// 加载热门概念涨幅前20
const loadPopularConcepts = async () => {
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
@@ -45,22 +44,29 @@ const PopularKeywords = ({ onKeywordClick }) => {
concept_id: item.concept_id
}));
setKeywords(formattedData);
logger.debug('PopularKeywords', '热门概念加载成功', {
logger.debug('PopularKeywords', '热门概念加载成功(自己请求)', {
count: formattedData.length
});
}
} catch (error) {
logger.error('PopularKeywords', 'loadPopularConcepts', error);
setKeywords([]);
} finally {
setLoading(false);
}
};
// 组件挂载时加载数据
// 处理从父组件传入的数据
useEffect(() => {
loadPopularConcepts();
}, []);
if (propKeywords && propKeywords.length > 0) {
// 使用父组件传入的数据
setKeywords(propKeywords);
logger.debug('PopularKeywords', '使用父组件传入的数据', {
count: propKeywords.length
});
} else {
// 没有 prop 数据,自己加载
loadPopularConcepts();
}
}, [propKeywords]);
// 根据涨跌幅获取标签颜色
const getTagColor = (changePct) => {
@@ -83,107 +89,113 @@ const PopularKeywords = ({ onKeywordClick }) => {
return `${formatted}%`;
};
// 处理概念标签点击 - 跳转到对应概念页面
// ✅ 修复:处理概念标签点击
const handleConceptClick = (concept) => {
// 如果原有的 onKeywordClick 存在,可以选择是否还要调用
// onKeywordClick && onKeywordClick(concept.keyword);
// 跳转到对应概念的页面
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
window.open(url, '_blank');
// 优先调用父组件传入的回调(用于搜索框显示和触发搜索)
if (onKeywordClick) {
onKeywordClick(concept.keyword);
logger.debug('PopularKeywords', '调用 onKeywordClick 回调', {
keyword: concept.keyword
});
} else {
// 如果没有回调,则跳转到对应概念的页面(原有行为)
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
window.open(url, '_blank');
logger.debug('PopularKeywords', '跳转到概念页面', {
keyword: concept.keyword,
url
});
}
};
// 查看更多概念
const handleViewMore = () => {
const url = `${DOMAIN_PREFIX}/concepts`;
window.open(url, '_blank');
// 处理"更多概念"按钮点击 - 跳转到概念中心
const handleMoreClick = () => {
navigate('/concepts');
};
return (
<Card
title={
<span>
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
热门概念
</span>
}
className="popular-keywords"
style={{ marginBottom: 16 }}
extra={
<span style={{ fontSize: 12, color: '#999' }}>
涨幅TOP20
</span>
}
>
<Spin spinning={loading}>
{keywords && keywords.length > 0 ? (
<>
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
{keywords.map((item) => (
<Tag
key={item.concept_id}
color={getTagColor(item.change_pct)}
style={{
cursor: 'pointer',
marginBottom: 8,
padding: '2px 8px',
transition: 'all 0.3s'
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span>{item.keyword}</span>
<span style={{
marginLeft: 6,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 4,
fontSize: 11,
opacity: 0.8
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 查看更多按钮 */}
<div style={{
borderTop: '1px solid #f0f0f0',
paddingTop: 12,
textAlign: 'center'
<>
{keywords && keywords.length > 0 && (
<div style={{ position: 'relative' }}>
<Space
size={[6, 6]}
wrap
style={{
alignItems: 'center',
maxHeight: '62px', // 约两行的高度 (每行约28-30px)
overflow: 'hidden',
paddingRight: '90px' // 为右侧按钮留出空间
}}
>
{/* 标题 */}
<span style={{
color: '#ff4d4f',
fontSize: 13,
fontWeight: 500,
marginRight: 4
}}>
<Button
type="link"
onClick={handleViewMore}
热门概念:
</span>
{/* 所有标签 */}
{keywords.map((item, index) => (
<Tag
key={item.concept_id || `keyword-${index}`}
color={getTagColor(item.change_pct)}
style={{
color: '#1890ff',
fontWeight: 500
cursor: 'pointer',
padding: '1px 6px',
fontSize: 12,
transition: 'all 0.3s',
margin: 0
}}
onClick={() => handleConceptClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
查看更多概念
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
</Button>
</div>
</>
) : (
<Empty
description="暂无热门概念"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Card>
<span>{item.keyword}</span>
<span style={{
marginLeft: 4,
fontWeight: 'bold'
}}>
{formatChangePct(item.change_pct)}
</span>
<span style={{
marginLeft: 3,
fontSize: 10,
opacity: 0.75
}}>
({item.count})
</span>
</Tag>
))}
</Space>
{/* 更多概念按钮 - 固定在第二行右侧 */}
<Button
type="link"
size="small"
onClick={handleMoreClick}
style={{
position: 'absolute',
bottom: 0,
right: 0,
fontSize: 12,
padding: '0 4px',
height: 'auto'
}}
>
更多概念 <RightOutlined style={{ fontSize: 10 }} />
</Button>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,679 @@
// src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined
} from '@ant-design/icons';
import moment from 'moment';
import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN';
import debounce from 'lodash/debounce';
import { useIndustry } from '../../../contexts/IndustryContext';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import PopularKeywords from './PopularKeywords';
const { RangePicker } = DatePicker;
const { Option } = AntSelect;
const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {}
}) => {
// 其他状态
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]);
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState('all'); // 重要性
const [dateRange, setDateRange] = useState(null); // 日期范围
// ✅ 本地输入状态 - 管理用户的实时输入
const [inputValue, setInputValue] = useState('');
// 使用全局行业数据
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
// 搜索触发函数
const triggerSearch = useCallback((params) => {
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
params: params,
timestamp: new Date().toISOString()
});
onSearch(params);
}, [onSearch]);
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
params: params,
delayMs: 300
});
triggerSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [triggerSearch]);
// 加载所有股票数据
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
count: response.data.length
});
}
};
loadStocks();
}, []);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
// 辅助函数:递归查找行业代码的完整路径
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// ✅ 从 props.filters 初始化筛选条件和输入框值
useEffect(() => {
if (!filters) return;
// 初始化排序和重要性
if (filters.sort) setSort(filters.sort);
if (filters.importance) setImportance(filters.importance);
// ✅ 初始化日期范围
if (filters.date_range) {
const parts = filters.date_range.split(' 至 ');
if (parts.length === 2) {
setDateRange([dayjs(parts[0]), dayjs(parts[1])]);
logger.debug('UnifiedSearchBox', '初始化日期范围', {
date_range: filters.date_range
});
}
}
// ✅ 初始化行业分类(需要 industryData 加载完成)
if (filters.industry_code && industryData && industryData.length > 0) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
logger.debug('UnifiedSearchBox', '初始化行业分类', {
industry_code: filters.industry_code,
path
});
}
}
// ✅ 同步 filters.q 到输入框显示值
if (filters.q) {
setInputValue(filters.q);
} else if (!filters.q) {
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
}, [filters.sort, filters.importance, filters.date_range, filters.industry_code, filters.q, industryData, findIndustryPath]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
// 使用 stockService 进行模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
// 转换为 AutoComplete 选项格式
const options = results.map(stock => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
</div>
),
// 保存完整的股票信息,用于选中后显示
stockInfo: stock
}));
setStockOptions(options);
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
query: value,
resultCount: options.length
});
};
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code,
name: stockInfo.name
});
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
const params = buildFilterParams({
q: stockInfo.code, // 使用股票代码作为搜索关键词
industry_code: ''
});
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
triggerSearch(params);
}
};
// ✅ 日期范围变化(使用防抖)
const handleDateRangeChange = (dates) => {
logger.debug('UnifiedSearchBox', '【1/5】日期范围值改变', {
oldValue: dateRange,
newValue: dates
});
setDateRange(dates);
// ⚠️ 注意:setState是异步的,此时dateRange仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
dateRange: dateRange, // 旧值
sort: sort,
importance: importance,
industryValue: industryValue
});
// 使用防抖搜索(需要从新值推导参数)
const params = {
...buildFilterParams(),
date_range: dates ? `${dates[0].format('YYYY-MM-DD')}${dates[1].format('YYYY-MM-DD')}` : ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 重要性变化(使用防抖)
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// ⚠️ 注意:setState是异步的,此时importance仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
importance: importance, // 旧值
sort: sort,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ importance: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 排序变化(使用防抖)
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// ⚠️ 注意:setState是异步的,此时sort仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
sort: sort, // 旧值
importance: importance,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 行业分类变化(使用防抖)
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// ⚠️ 注意:setState是异步的,此时industryValue仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
industryValue: industryValue, // 旧值
sort: sort,
importance: importance,
dateRange: dateRange
});
// 使用防抖搜索 (需要从新值推导参数)
const params = {
...buildFilterParams(),
industry_code: value?.[value.length - 1] || ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
}
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
const handleKeywordClick = (keyword) => {
// 更新输入框显示
setInputValue(keyword);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
});
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
triggerSearch(params);
};
// 主搜索(点击搜索按钮或回车)
const handleMainSearch = () => {
// 取消之前的防抖
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
const params = buildFilterParams({
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
industry_code: ''
});
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
});
triggerSearch(params);
};
// ✅ 处理输入变化 - 更新本地输入状态
const handleInputChange = (value) => {
logger.debug('UnifiedSearchBox', '输入变化', { value });
setInputValue(value);
};
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
const buildFilterParams = useCallback((overrides = {}) => {
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
overrides: overrides,
currentState: {
sort,
importance,
dateRange,
industryValue,
'filters.q': filters.q
}
});
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: overrides.sort ?? sort,
importance: overrides.importance ?? importance,
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}` : '',
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
// 最终 overrides 具有最高优先级
...overrides
};
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, dateRange, filters.q, industryValue]);
// ✅ 应用筛选(立即搜索,取消防抖)
const handleApplyFilters = () => {
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const params = buildFilterParams();
logger.debug('UnifiedSearchBox', '应用筛选,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance('all');
setDateRange(null);
// 输出重置后的完整参数
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all',
date_range: '',
page: 1
};
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
onSearch(resetParams);
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
const filterTags = useMemo(() => {
const tags = [];
// 搜索关键词标签 - 从 filters.q 读取
if (filters.q) {
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
}
// 行业标签
if (industryValue && industryValue.length > 0) {
const industryLabel = industryValue.slice(1).join(' > ');
tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
}
// 日期范围标签
if (dateRange && dateRange.length === 2) {
const dateLabel = `${dateRange[0].format('YYYY-MM-DD')}${dateRange[1].format('YYYY-MM-DD')}`;
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
}
// 重要性标签(排除默认值 'all'
if (importance && importance !== 'all') {
tags.push({ key: 'importance', label: `重要性: ${importance}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags;
}, [filters.q, industryValue, dateRange, importance, sort]);
// ✅ 移除单个标签 - 构建新参数并触发搜索
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
const params = buildFilterParams({ q: '' });
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
triggerSearch(params);
} else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
} else if (key === 'date_range') {
// 清除日期范围
setDateRange(null);
const params = buildFilterParams({ date_range: '' });
triggerSearch(params);
} else if (key === 'importance') {
// 重置重要性为默认值
setImportance('all');
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}
};
return (
<Card>
{/* 第一行:主搜索框 */}
<Space.Compact style={{ width: '100%', marginBottom: 12 }} size="large">
<SearchOutlined style={{
fontSize: 20,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#666'
}} />
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onSearch={handleSearch}
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
style={{ flex: 1 }}
size="large"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
<Button
type="primary"
onClick={handleMainSearch}
size="large"
style={{ minWidth: 80 }}
>
搜索
</Button>
</Space.Compact>
{/* 第二行:热门概念 */}
<div style={{ marginBottom: 12 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
{/* 第三行:筛选器 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业分类"
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 200 }}
size="middle"
/>
{/* 日期范围 */}
<RangePicker
value={dateRange}
onChange={handleDateRangeChange}
locale={locale}
placeholder={['开始日期', '结束日期']}
style={{ width: 240 }}
size="middle"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<AntSelect
value={importance}
onChange={handleImportanceChange}
style={{ width: 100 }}
size="middle"
>
<Option value="all">全部</Option>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
</AntSelect>
</Space>
{/* 重置按钮 - 现代化设计 */}
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="middle"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 12px',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff4d4f';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.backgroundColor = '#fff1f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = '#666';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
重置
</Button>
</Space>
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 120 }}
size="middle"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
</AntSelect>
</Space>
</Space>
{/* 已选条件标签 */}
{filterTags.length > 0 && (
<Space size={[8, 8]} wrap style={{ marginTop: 12 }}>
{filterTags.map(tag => (
<Tag
key={tag.key}
closable
onClose={() => handleRemoveTag(tag.key)}
color="blue"
>
{tag.label}
</Tag>
))}
</Space>
)}
</Card>
);
};
export default UnifiedSearchBox;

View File

@@ -0,0 +1,123 @@
// src/views/Community/hooks/useEventData.js
// 事件数据加载逻辑 Hook
import { useState, useEffect, useRef, useCallback } from 'react';
import { debounce } from 'lodash';
import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
/**
* 事件数据加载 Hook
* @param {Object} filters - 筛选条件
* @param {number} pageSize - 每页数量
* @returns {Object} 事件数据和加载状态
*/
export const useEventData = (filters, pageSize = 10) => {
const [events, setEvents] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: pageSize,
total: 0
});
const [loading, setLoading] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
// 加载事件列表
// ✅ 修复闭包陷阱: 接受 currentFilters 参数,避免使用闭包中的旧 filters
const loadEvents = useCallback(async (page = 1, currentFilters = null) => {
// 使用传入的 currentFilters 或回退到闭包中的 filters
const filtersToUse = currentFilters || filters;
const requestParams = {
...filtersToUse,
page,
per_page: pagination.pageSize
};
logger.debug('useEventData', '📡 【准备发起API请求】loadEvents 被调用', {
page,
currentFilters,
filtersToUse,
requestParams
});
setLoading(true);
try {
logger.debug('useEventData', '🌐 正在调用 eventService.getEvents', { requestParams });
const response = await eventService.getEvents(requestParams);
logger.debug('useEventData', '✅ API响应成功', {
success: response.success,
eventCount: response.data?.events?.length,
total: response.data?.pagination?.total
});
if (response.success) {
setEvents(response.data.events);
setPagination({
current: response.data.pagination.page,
pageSize: response.data.pagination.per_page,
total: response.data.pagination.total
});
setLastUpdateTime(new Date());
logger.debug('useEventData', 'loadEvents 成功', {
count: response.data.events.length,
total: response.data.pagination.total
});
}
} catch (error) {
logger.error('useEventData', '❌ loadEvents 失败', error, {
page,
filtersToUse
});
} finally {
setLoading(false);
}
}, [filters, pagination.pageSize]);
// 创建防抖的 loadEvents 函数500ms 防抖延迟)
// ✅ 修复闭包陷阱: 防抖函数接受 filters 参数并传递给 loadEvents
const debouncedLoadEvents = useRef(
debounce((page, filters) => {
logger.debug('useEventData', '⏱️ 【防抖延迟500ms结束】即将执行 loadEvents', {
page,
filters
});
loadEvents(page, filters);
}, 500)
).current;
// 监听 filters 变化,自动加载数据
// 防抖优化:用户快速切换筛选条件时,只执行最后一次请求
useEffect(() => {
logger.debug('useEventData', '🔔 【filters变化触发useEffect】完整filters对象:', filters);
logger.debug('useEventData', '详细参数:', {
page: filters.page || 1,
sort: filters.sort,
importance: filters.importance,
date_range: filters.date_range,
q: filters.q,
industry_code: filters.industry_code,
timestamp: new Date().toISOString()
});
// ✅ 使用防抖加载事件,将当前 filters 传递给防抖函数
logger.debug('useEventData', '⏰ 启动防抖计时器(500ms)传递最新filters');
debouncedLoadEvents(filters.page || 1, filters);
// 组件卸载时取消防抖
return () => {
debouncedLoadEvents.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]); // 监听 filters 状态变化
return {
events,
pagination,
loading,
lastUpdateTime,
loadEvents
};
};

View File

@@ -0,0 +1,79 @@
// src/views/Community/hooks/useEventFilters.js
// 事件筛选逻辑 Hook
import { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { logger } from '../../../utils/logger';
/**
* 事件筛选逻辑 Hook
* @param {Object} options - 配置选项
* @param {Function} options.navigate - 路由导航函数
* @param {Function} options.onEventClick - 事件点击回调
* @param {Object} options.eventTimelineRef - 时间轴ref用于滚动
* @returns {Object} 筛选状态和处理函数
*/
export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {}) => {
const [searchParams] = useSearchParams();
// 筛选参数状态 - 初始化时从URL读取之后只用本地状态
const [filters, setFilters] = useState(() => {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '',
page: parseInt(searchParams.get('page') || '1', 10)
};
});
// 更新筛选参数 - 直接替换(由 UnifiedSearchBox 输出完整参数)
const updateFilters = useCallback((newFilters) => {
logger.debug('useEventFilters', '🔄 【接收到onSearch回调】updateFilters 接收到完整参数', {
newFilters: newFilters,
oldFilters: filters,
timestamp: new Date().toISOString()
});
setFilters(newFilters);
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
}, [filters]);
// 处理分页变化
const handlePageChange = useCallback((page) => {
// 保持现有筛选条件,只更新页码
updateFilters({ ...filters, page });
// 滚动到实时事件时间轴(平滑滚动)
if (eventTimelineRef && eventTimelineRef.current) {
setTimeout(() => {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 滚动到元素顶部
});
}, 100); // 延迟100ms确保DOM更新
}
}, [filters, updateFilters, eventTimelineRef]);
// 处理事件点击
const handleEventClick = useCallback((event) => {
if (onEventClick) {
onEventClick(event);
}
}, [onEventClick]);
// 处理查看详情
const handleViewDetail = useCallback((eventId) => {
if (navigate) {
navigate(`/event-detail/${eventId}`);
}
}, [navigate]);
return {
filters,
updateFilters,
handlePageChange,
handleEventClick,
handleViewDetail
};
};

View File

@@ -210,8 +210,6 @@
/* 热点事件部分样式 */
.hot-events-section {
margin-top: 48px;
padding: 32px;
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);

View File

@@ -1,291 +1,63 @@
// src/views/Community/index.js
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
import {
Box,
Container,
Grid,
GridItem,
Card,
CardBody,
CardHeader,
Button,
Text,
Heading,
VStack,
HStack,
Badge,
Spinner,
Flex,
Tag,
TagLabel,
TagCloseButton,
IconButton,
Wrap,
WrapItem,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
useDisclosure,
Center,
Image,
Divider,
useColorModeValue,
Link,
} from '@chakra-ui/react';
import {
RepeatIcon,
TimeIcon,
InfoIcon,
SearchIcon,
CalendarIcon,
StarIcon,
ChevronRightIcon,
CloseIcon,
} from '@chakra-ui/icons';
// 导入组件
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
import EventFilters from './components/EventFilters';
import EventList from './components/EventList';
import EventDetailModal from './components/EventDetailModal';
import StockDetailPanel from './components/StockDetailPanel';
import SearchBox from './components/SearchBox';
import PopularKeywords from './components/PopularKeywords';
import HotEvents from './components/HotEvents';
import ImportanceLegend from './components/ImportanceLegend';
import InvestmentCalendar from './components/InvestmentCalendar';
import { eventService } from '../../services/eventService';
import EventTimelineCard from './components/EventTimelineCard';
import HotEventsSection from './components/HotEventsSection';
import EventModals from './components/EventModals';
// 导入自定义 Hooks
import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters';
import { logger } from '../../utils/logger';
import { useNotification } from '../../contexts/NotificationContext';
// 导航栏已由 MainLayout 提供,无需在此导入
const filterLabelMap = {
date_range: v => v ? `日期: ${v}` : '',
sort: v => v ? `排序: ${v === 'new' ? '最新' : v === 'hot' ? '热门' : v === 'returns' ? '收益率' : v}` : '',
importance: v => v && v !== 'all' ? `重要性: ${v}` : '',
industry_classification: v => v ? `行业: ${v}` : '',
industry_code: v => v ? `行业代码: ${v}` : '',
q: v => v ? `关键词: ${v}` : '',
};
const Community = () => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const dispatch = useDispatch();
// Redux状态
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// Modal/Drawer控制
const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure();
const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure();
// Ref用于滚动到实时事件时间轴
const eventTimelineRef = useRef(null);
const hasScrolledRef = useRef(false); // 标记是否已滚动
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
// 状态管理
const [events, setEvents] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
const [loading, setLoading] = useState(false);
// Modal/Drawer状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
const [popularKeywords, setPopularKeywords] = useState([]);
const [hotEvents, setHotEvents] = useState([]);
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
// 从URL获取筛选参数
const getFiltersFromUrl = useCallback(() => {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
search_type: searchParams.get('search_type') || 'topic',
industry_classification: searchParams.get('industry_classification') || '',
industry_code: searchParams.get('industry_code') || '',
page: parseInt(searchParams.get('page') || '1', 10)
};
}, [searchParams]);
// 自定义 Hooks
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
navigate,
onEventClick: (event) => setSelectedEventForStock(event),
eventTimelineRef
});
// 更新URL参数
const updateUrlParams = useCallback((params) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(params).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
setSearchParams(newParams);
}, [searchParams, setSearchParams]);
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
// 加载事件列表
const loadEvents = useCallback(async (page = 1) => {
logger.debug('Community', 'loadEvents 被调用', { page });
setLoading(true);
try {
const filters = getFiltersFromUrl();
const response = await eventService.getEvents({
...filters,
page,
per_page: pagination.pageSize
});
if (response.success) {
setEvents(response.data.events);
setPagination({
current: response.data.pagination.page,
pageSize: response.data.pagination.per_page,
total: response.data.pagination.total
});
setLastUpdateTime(new Date());
}
} catch (error) {
// ❌ 移除 toast仅 console 输出
logger.error('Community', 'loadEvents', error, {
page,
filters: getFiltersFromUrl()
});
} finally {
setLoading(false);
}
}, [getFiltersFromUrl, pagination.pageSize]); // ✅ 移除 toast 依赖
// 加载热门关键词
const loadPopularKeywords = useCallback(async () => {
try {
const response = await eventService.getPopularKeywords(20);
if (response.success) {
setPopularKeywords(response.data);
logger.debug('Community', '热门关键词加载成功', {
count: response.data?.length || 0
});
}
} catch (error) {
logger.error('Community', 'loadPopularKeywords', error);
}
}, []);
// 加载热点事件
const loadHotEvents = useCallback(async () => {
try {
const response = await eventService.getHotEvents({ days: 5, limit: 4 });
if (response.success) {
setHotEvents(response.data);
logger.debug('Community', '热点事件加载成功', {
count: response.data?.length || 0
});
}
} catch (error) {
logger.error('Community', 'loadHotEvents', error);
}
}, []);
// 处理筛选变化
const handleFilterChange = useCallback((filterType, value) => {
updateUrlParams({ [filterType]: value, page: 1 });
}, [updateUrlParams]);
// 处理分页变化
const handlePageChange = useCallback((page) => {
updateUrlParams({ page });
loadEvents(page);
// 滚动到实时事件时间轴(平滑滚动)
setTimeout(() => {
if (eventTimelineRef.current) {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 滚动到元素顶部
});
}
}, 100); // 延迟100ms确保DOM更新
}, [updateUrlParams, loadEvents]);
// 处理事件点击
const handleEventClick = useCallback((event) => {
setSelectedEventForStock(event);
onStockDrawerOpen();
}, [onStockDrawerOpen]);
// 处理查看详情
const handleViewDetail = useCallback((eventId) => {
navigate(`/event-detail/${eventId}`);
}, [navigate]);
// 处理关键词点击
const handleKeywordClick = useCallback((keyword) => {
updateUrlParams({ q: keyword, page: 1 });
}, [updateUrlParams]);
// 处理标签删除
const handleRemoveFilterTag = (key) => {
let reset = '';
if (key === 'sort') reset = 'new';
if (key === 'importance') reset = 'all';
updateUrlParams({ [key]: reset, page: 1 });
loadEvents(1);
};
// 获取筛选标签
const filters = getFiltersFromUrl();
const filterTags = Object.entries(filters)
.filter(([key, value]) => {
if (key === 'industry_code') return !!value;
if (key === 'importance') return value && value !== 'all';
if (key === 'sort') return value && value !== 'new';
if (key === 'date_range') return !!value;
if (key === 'q') return !!value;
return false;
})
.map(([key, value]) => {
if (key === 'industry_code') return { key, label: `行业代码: ${value}` };
return { key, label: filterLabelMap[key] ? filterLabelMap[key](value) : `${key}: ${value}` };
});
// 初始化加载
// 注意: 只监听 searchParams 变化,不监听 loadEvents 等函数
// 这是为了避免 StockDetailPanel 打开时触发不必要的重新加载
// 如果未来 loadEvents 添加了新的状态依赖,需要在此处同步更新
// 加载热门关键词和热点事件使用Redux内部有缓存判断
useEffect(() => {
logger.debug('Community', 'useEffect 触发searchParams 变化', {
params: searchParams.toString()
});
const page = parseInt(searchParams.get('page') || '1', 10);
loadEvents(page);
loadPopularKeywords();
loadHotEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); // 只监听 URL 参数变化
dispatch(fetchPopularKeywords());
dispatch(fetchHotEvents());
}, [dispatch]);
// ⚡ 首次访问社区时,延迟显示权限引导
useEffect(() => {
@@ -299,218 +71,56 @@ const Community = () => {
}
}, [showCommunityGuide]); // 只在组件挂载时执行一次
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
const scrollToTimeline = useCallback(() => {
if (!hasScrolledRef.current && eventTimelineRef.current) {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动动画
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
inline: 'nearest' // 水平方向最小滚动
});
hasScrolledRef.current = true; // 标记已滚动
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
}
}, []);
return (
<Box minH="100vh" bg={bgColor}>
{/* 导航栏已由 MainLayout 提供 */}
{/* Midjourney风格英雄区域 */}
<MidjourneyHeroSection />
{/* 主内容区域 */}
<Container maxW="container.xl" py={8}>
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
{/* 左侧主要内容 */}
<GridItem>
{/* 筛选器 - 需要改造为Chakra UI版本 */}
<Card mb={4} bg={cardBg} borderColor={borderColor}>
<CardBody>
<EventFilters
filters={filters}
onFilterChange={handleFilterChange}
loading={loading}
/>
</CardBody>
</Card>
<Container maxW="container.xl" pt={6} pb={8}>
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />
{/* 筛选标签 */}
{filterTags.length > 0 && (
<Wrap spacing={2} mb={4}>
{filterTags.map(tag => (
<WrapItem key={tag.key}>
<Tag size="md" variant="solid" colorScheme="blue">
<TagLabel>{tag.label}</TagLabel>
<TagCloseButton onClick={() => handleRemoveFilterTag(tag.key)} />
</Tag>
</WrapItem>
))}
</Wrap>
)}
{/* 事件列表卡片 */}
<Card ref={eventTimelineRef} bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时事件时间轴</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="green">全网监控</Badge>
<Badge colorScheme="orange">智能捕获</Badge>
<Badge colorScheme="purple">深度分析</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime.toLocaleTimeString()}
</Text>
</Flex>
</CardHeader>
<CardBody>
{loading ? (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
) : events.length > 0 ? (
<EventList
events={events}
pagination={pagination}
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
) : (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
)}
</CardBody>
</Card>
</GridItem>
{/* 右侧侧边栏 */}
<GridItem>
<VStack spacing={4}>
{/* 搜索框 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardBody>
<SearchBox
onSearch={(values) => {
updateUrlParams({ ...values, page: 1 });
}}
/>
</CardBody>
</Card>
{/* 投资日历 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<CalendarIcon />
<Text>投资日历</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<InvestmentCalendar />
</CardBody>
</Card>
{/* 热门关键词 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<StarIcon />
<Text>热门关键词</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</CardBody>
</Card>
{/* 重要性说明 - 需要改造为Chakra UI版本 */}
<Card w="full" bg={cardBg}>
<CardHeader>
<Heading size="sm">
<HStack>
<InfoIcon />
<Text>重要性说明</Text>
</HStack>
</Heading>
</CardHeader>
<CardBody>
<ImportanceLegend />
</CardBody>
</Card>
</VStack>
</GridItem>
</Grid>
{/* 热点事件 - 需要改造为Chakra UI版本 */}
{hotEvents.length > 0 && (
<Card mt={8} bg={cardBg}>
<CardHeader>
<Heading size="md">🔥 热点事件</Heading>
</CardHeader>
<CardBody>
<HotEvents events={hotEvents} />
</CardBody>
</Card>
)}
{/* 实时事件 */}
<EventTimelineCard
ref={eventTimelineRef}
mt={6}
events={events}
loading={loading}
pagination={pagination}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}
onSearch={updateFilters}
onSearchFocus={scrollToTimeline}
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="container.xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
{/* 事件详情模态框 - 使用Chakra UI Modal */}
<Modal isOpen={isEventModalOpen && selectedEvent} onClose={onEventModalClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>事件详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<EventDetailModal
event={selectedEvent}
onClose={() => {
setSelectedEvent(null);
onEventModalClose();
}}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer避免与 Chakra Drawer 重叠导致空白 */}
<StockDetailPanel
visible={!!selectedEventForStock}
event={selectedEventForStock}
onClose={() => {
setSelectedEventForStock(null);
onStockDrawerClose();
{/* 事件弹窗 */}
<EventModals
eventModalState={{
isOpen: !!selectedEvent,
onClose: () => setSelectedEvent(null),
event: selectedEvent,
onEventClose: () => setSelectedEvent(null)
}}
stockDrawerState={{
visible: !!selectedEventForStock,
event: selectedEventForStock,
onClose: () => setSelectedEventForStock(null)
}}
/>
</Box>

View File

@@ -473,6 +473,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
}
];
// 数组安全检查
if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无资产负债表数据
</Alert>
);
}
const maxColumns = Math.min(balanceSheet.length, 6);
const displayData = balanceSheet.slice(0, maxColumns);
@@ -707,6 +717,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
}
];
// 数组安全检查
if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无利润表数据
</Alert>
);
}
const maxColumns = Math.min(incomeStatement.length, 6);
const displayData = incomeStatement.slice(0, maxColumns);
@@ -866,6 +886,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
];
// 数组安全检查
if (!Array.isArray(cashflow) || cashflow.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无现金流量表数据
</Alert>
);
}
const maxColumns = Math.min(cashflow.length, 8);
const displayData = cashflow.slice(0, maxColumns);
@@ -1069,6 +1099,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
}
};
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无财务指标数据
</Alert>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const currentCategory = metricsCategories[selectedCategory];
@@ -1426,8 +1466,9 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
return (
<VStack spacing={4} align="stretch">
{industryRank.map((periodData, periodIdx) => (
<Card key={periodIdx}>
{Array.isArray(industryRank) && industryRank.length > 0 ? (
industryRank.map((periodData, periodIdx) => (
<Card key={periodIdx}>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
@@ -1486,7 +1527,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
))}
</CardBody>
</Card>
))}
))
) : (
<Card>
<CardBody>
<Text textAlign="center" color="gray.500" py={8}>
暂无行业排名数据
</Text>
</CardBody>
</Card>
)}
</VStack>
);
};
@@ -1738,7 +1788,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
// 综合对比分析
const ComparisonAnalysis = () => {
if (!comparison || comparison.length === 0) return null;
if (!Array.isArray(comparison) || comparison.length === 0) return null;
const revenueData = comparison.map(item => ({
period: formatUtils.getReportType(item.period),

View File

@@ -1471,7 +1471,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</HStack>
</StatLabel>
<StatNumber color={theme.textPrimary} fontSize="lg">
{minuteData.data[0]?.open.toFixed(2)}
{minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'}
</StatNumber>
</Stat>
<Stat>
@@ -1485,13 +1485,15 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
color={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? theme.success : theme.danger}
fontSize="lg"
>
{minuteData.data[minuteData.data.length - 1]?.close.toFixed(2)}
{minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'}
</StatNumber>
<StatHelpText fontSize="xs">
<StatArrow
type={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? 'increase' : 'decrease'}
/>
{Math.abs(((minuteData.data[minuteData.data.length - 1]?.close - minuteData.data[0]?.open) / minuteData.data[0]?.open * 100)).toFixed(2)}%
{(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null)
? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2)
: '0.00'}%
</StatHelpText>
</Stat>
<Stat>
@@ -1502,7 +1504,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</HStack>
</StatLabel>
<StatNumber color={theme.success} fontSize="lg">
{Math.max(...minuteData.data.map(item => item.high)).toFixed(2)}
{(() => {
const highs = minuteData.data.map(item => item.high).filter(h => h != null);
return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-';
})()}
</StatNumber>
</Stat>
<Stat>
@@ -1513,7 +1518,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</HStack>
</StatLabel>
<StatNumber color={theme.danger} fontSize="lg">
{Math.min(...minuteData.data.map(item => item.low)).toFixed(2)}
{(() => {
const lows = minuteData.data.map(item => item.low).filter(l => l != null);
return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-';
})()}
</StatNumber>
</Stat>
</SimpleGrid>
@@ -1558,7 +1566,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
平均价格
</Text>
<Text fontSize="sm" color={theme.textPrimary}>
{(minuteData.data.reduce((sum, item) => sum + item.close, 0) / minuteData.data.length).toFixed(2)}
{(() => {
const closes = minuteData.data.map(item => item.close).filter(c => c != null);
return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-';
})()}
</Text>
</Box>
<Box>
@@ -1744,7 +1755,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元
</Badge>
<Badge colorScheme="purple" fontSize="md">
均价: {dayStats.avg_price.toFixed(2)}
均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}
</Badge>
</HStack>
</HStack>
@@ -1766,23 +1777,23 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
{dayStats.deals.map((deal, i) => (
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
<Tooltip label={deal.buyer_dept} placement="top">
<Text>{deal.buyer_dept}</Text>
<Tooltip label={deal.buyer_dept || '-'} placement="top">
<Text>{deal.buyer_dept || '-'}</Text>
</Tooltip>
</Td>
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
<Tooltip label={deal.seller_dept} placement="top">
<Text>{deal.seller_dept}</Text>
<Tooltip label={deal.seller_dept || '-'} placement="top">
<Text>{deal.seller_dept || '-'}</Text>
</Tooltip>
</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
{deal.price.toFixed(2)}
{deal.price != null ? deal.price.toFixed(2) : '-'}
</Td>
<Td isNumeric color={theme.textPrimary}>
{deal.volume.toFixed(2)}
{deal.volume != null ? deal.volume.toFixed(2) : '-'}
</Td>
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
{deal.amount.toFixed(2)}
{deal.amount != null ? deal.amount.toFixed(2) : '-'}
</Td>
</Tr>
))}
@@ -1845,22 +1856,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
买入前五
</Text>
<VStack spacing={1} align="stretch">
{dayData.buyers.slice(0, 5).map((buyer, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
{buyer.dept_name}
</Text>
<Text fontSize="sm" color={theme.success} fontWeight="bold">
{formatUtils.formatNumber(buyer.buy_amount)}
</Text>
</HStack>
))}
{dayData.buyers && dayData.buyers.length > 0 ? (
dayData.buyers.slice(0, 5).map((buyer, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
{buyer.dept_name}
</Text>
<Text fontSize="sm" color={theme.success} fontWeight="bold">
{formatUtils.formatNumber(buyer.buy_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
)}
</VStack>
</Box>
@@ -1869,22 +1884,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
卖出前五
</Text>
<VStack spacing={1} align="stretch">
{dayData.sellers.slice(0, 5).map((seller, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
{seller.dept_name}
</Text>
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
{formatUtils.formatNumber(seller.sell_amount)}
</Text>
</HStack>
))}
{dayData.sellers && dayData.sellers.length > 0 ? (
dayData.sellers.slice(0, 5).map((seller, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
borderRadius="md"
>
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
{seller.dept_name}
</Text>
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
{formatUtils.formatNumber(seller.sell_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
)}
</VStack>
</Box>
</Grid>
@@ -1948,19 +1967,27 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
</Tr>
</Thead>
<Tbody>
{pledgeData.map((item, idx) => (
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
<Td color={theme.textPrimary}>{item.end_date}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
<Td isNumeric color={theme.warning} fontWeight="bold">
{formatUtils.formatPercent(item.pledge_ratio)}
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
pledgeData.map((item, idx) => (
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
<Td color={theme.textPrimary}>{item.end_date}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
<Td isNumeric color={theme.warning} fontWeight="bold">
{formatUtils.formatPercent(item.pledge_ratio)}
</Td>
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
</Tr>
))
) : (
<Tr>
<Td colSpan={7} textAlign="center" py={8}>
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
</Td>
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
</Tr>
))}
)}
</Tbody>
</Table>
</TableContainer>

View File

@@ -67,7 +67,11 @@ export default function CenterDashboard() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
// 颜色主题
const textColor = useColorModeValue('gray.700', 'white');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -107,13 +111,13 @@ export default function CenterDashboard() {
if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []);
} catch (err) {
logger.error('Center', 'loadData', err, {
userId: user?.id,
userId,
timestamp: new Date().toISOString()
});
} finally {
setLoading(false);
}
}, [user?.id]); // 只依赖 user.id,避免无限循环
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
// 加载实时行情
const loadRealtimeQuotes = useCallback(async () => {
@@ -188,7 +192,14 @@ export default function CenterDashboard() {
};
useEffect(() => {
if (user && location.pathname.includes('/home/center')) {
const userIdChanged = prevUserIdRef.current !== userId;
if (userIdChanged) {
prevUserIdRef.current = userId;
}
// 只在 userId 真正变化或路径变化时加载数据
if ((userIdChanged || !prevUserIdRef.current) && user && location.pathname.includes('/home/center')) {
loadData();
}
@@ -199,7 +210,7 @@ export default function CenterDashboard() {
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [user?.id, location.pathname, loadData]); // 只依赖 user.id,避免无限循环
}, [userId, location.pathname, loadData, user]); // ⚡ 使用 userId防重复通过 ref 判断
// 定时刷新实时行情(每分钟一次)
useEffect(() => {

View File

@@ -39,7 +39,6 @@ import {
Center,
useToast,
Skeleton,
Link,
} from '@chakra-ui/react';
import { FiLock } from 'react-icons/fi';
import {
@@ -823,26 +822,6 @@ const EventDetail = () => {
</VStack>
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
{/* 升级弹窗 */}
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}

View File

@@ -13,7 +13,6 @@ import {
VStack,
HStack,
SimpleGrid,
Link,
useBreakpointValue
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
@@ -21,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import heroBg from '../../assets/img/BackgroundCard1.png';
import '../../styles/home-animations.css';
import { logger } from '../../utils/logger';
import MidjourneyHeroSection from '../Community/components/MidjourneyHeroSection';
export default function HomePage() {
const { user, isAuthenticated } = useAuth(); // ⚡ 移除 isLoading不再依赖它
@@ -34,7 +34,6 @@ export default function HomePage() {
const heroTextSize = useBreakpointValue({ base: 'md', md: 'lg', lg: 'xl' });
const containerPx = useBreakpointValue({ base: 10, md: 10, lg: 10 });
const showDecorations = useBreakpointValue({ base: false, md: true });
const isMobile = useBreakpointValue({ base: true, md: false });
// 保留原有的调试信息
useEffect(() => {
@@ -51,11 +50,11 @@ export default function HomePage() {
const coreFeatures = [
{
id: 'news-catalyst',
title: '新闻催化分析',
title: '新闻中心',
description: '实时新闻事件分析,捕捉市场催化因子',
icon: '📊',
color: 'yellow',
url: 'https://valuefrontier.cn/community',
url: '/community',
badge: '核心',
featured: true
},
@@ -65,7 +64,7 @@ export default function HomePage() {
description: '热门概念与主题投资分析追踪',
icon: '🎯',
color: 'purple',
url: 'https://valuefrontier.cn/concepts',
url: '/concepts',
badge: '热门'
},
{
@@ -74,7 +73,7 @@ export default function HomePage() {
description: '全面的个股基本面信息整合',
icon: '📈',
color: 'blue',
url: 'https://valuefrontier.cn/stocks',
url: '/stocks',
badge: '全面'
},
{
@@ -83,7 +82,7 @@ export default function HomePage() {
description: '涨停板数据深度分析与规律挖掘',
icon: '🚀',
color: 'green',
url: 'https://valuefrontier.cn/limit-analyse',
url: '/limit-analyse',
badge: '精准'
},
{
@@ -92,7 +91,7 @@ export default function HomePage() {
description: '个股全方位分析与投资决策支持',
icon: '🧭',
color: 'orange',
url: 'https://valuefrontier.cn/company?scode=688256',
url: '/company?scode=688256',
badge: '专业'
},
{
@@ -106,15 +105,6 @@ export default function HomePage() {
}
];
// 个人中心配置
// const personalCenter = {
// title: '个人中心',
// description: '账户管理与个人设置',
// icon: '👤',
// color: 'gray',
// url: 'https://valuefrontier.cn/home/center'
// };
// @TODO 如何区分内部链接和外部链接?
const handleProductClick = (url) => {
if (url.startsWith('http')) {
@@ -202,7 +192,7 @@ export default function HomePage() {
</>
)}
<Container maxW="7xl" position="relative" zIndex={2} px={containerPx}>
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
<VStack spacing={{ base: 8, md: 12, lg: 16 }} align="stretch" minH={heroHeight} justify="center">
{/* 主标题区域 */}
<VStack spacing={{ base: 4, md: 5, lg: 6 }} textAlign="center" pt={{ base: 4, md: 6, lg: 8 }}>
@@ -225,7 +215,7 @@ export default function HomePage() {
<Box pb={{ base: 8, md: 12 }}>
<VStack spacing={{ base: 6, md: 8 }}>
{/* 新闻催化分析 - 突出显示 */}
{/* 新闻中心 - 突出显示 */}
<Card
bg="transparent"
border="2px solid"
@@ -247,108 +237,77 @@ export default function HomePage() {
}}
>
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
{isMobile ? (
/* 移动端:垂直布局 */
<VStack spacing={4} align="stretch">
<HStack spacing={4}>
<Box
p={3}
borderRadius="lg"
bg="yellow.400"
color="black"
>
<Text fontSize="2xl">{coreFeatures[0].icon}</Text>
</Box>
<VStack align="start" spacing={1} flex={1}>
<Heading size="lg" color="white">
{/* 响应式布局:移动端纵向,桌面端横向 */}
<Flex
direction={{ base: 'column', md: 'row' }}
align={{ base: 'stretch', md: 'center' }}
justify={{ base: 'flex-start', md: 'space-between' }}
gap={{ base: 4, md: 6 }}
>
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
<Box
p={{ base: 3, md: 4 }}
borderRadius={{ base: 'lg', md: 'xl' }}
bg="yellow.400"
color="black"
>
<Text fontSize={{ base: '2xl', md: '3xl' }}>{coreFeatures[0].icon}</Text>
</Box>
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
<HStack>
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
{coreFeatures[0].title}
</Heading>
<Badge colorScheme="yellow" variant="solid" fontSize="xs">
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
{coreFeatures[0].badge}
</Badge>
</VStack>
</HStack>
<Text color="whiteAlpha.800" fontSize="md" lineHeight="tall">
{coreFeatures[0].description}
</Text>
<Button
colorScheme="yellow"
size="md"
borderRadius="full"
fontWeight="bold"
w="100%"
onClick={() => handleProductClick(coreFeatures[0].url)}
minH="44px"
>
进入功能
</Button>
</VStack>
) : (
/* 桌面端:横向布局 */
<Flex align="center" justify="space-between">
<HStack spacing={6}>
<Box
p={4}
borderRadius="xl"
bg="yellow.400"
color="black"
>
<Text fontSize="3xl">{coreFeatures[0].icon}</Text>
</Box>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="xl" color="white">
{coreFeatures[0].title}
</Heading>
<Badge colorScheme="yellow" variant="solid" fontSize="sm">
{coreFeatures[0].badge}
</Badge>
</HStack>
<Text color="whiteAlpha.800" fontSize="lg" maxW="md">
{coreFeatures[0].description}
</Text>
</VStack>
</HStack>
<Button
colorScheme="yellow"
size="lg"
borderRadius="full"
fontWeight="bold"
onClick={() => handleProductClick(coreFeatures[0].url)}
>
进入功能
</Button>
</HStack>
<Text color="whiteAlpha.800" fontSize={{ base: 'md', md: 'lg' }} maxW={{ md: 'md' }} lineHeight="tall">
{coreFeatures[0].description}
</Text>
</VStack>
</Flex>
)}
<Button
colorScheme="yellow"
size={{ base: 'md', md: 'lg' }}
borderRadius="full"
fontWeight="bold"
w={{ base: '100%', md: 'auto' }}
onClick={() => handleProductClick(coreFeatures[0].url)}
minH="44px"
flexShrink={0}
>
进入功能
</Button>
</Flex>
</CardBody>
</Card>
{/* 其他5个功能 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 4, md: 5, lg: 6 }} w="100%">
{coreFeatures.slice(1).map((feature) => (
<Card
key={feature.id}
bg="whiteAlpha.100"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius={{ base: 'xl', md: '2xl' }}
cursor="pointer"
transition="all 0.3s ease"
_hover={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-4px)',
shadow: '2xl'
}}
_active={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-2px)'
}}
onClick={() => handleProductClick(feature.url)}
minH={{ base: 'auto', md: '180px' }}
>
<Card
key={feature.id}
bg="whiteAlpha.100"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius={{ base: 'xl', md: '2xl' }}
transition="all 0.3s ease"
_hover={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-4px)',
shadow: '2xl'
}}
_active={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-2px)'
}}
onClick={() => handleProductClick(feature.url)}
minH={{ base: 'auto', md: '180px' }}
>
<CardBody p={{ base: 5, md: 6 }}>
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
<HStack>
@@ -396,35 +355,14 @@ export default function HomePage() {
</SimpleGrid>
</VStack>
</Box>
{/* Midjourney风格英雄区域 */}
<MidjourneyHeroSection />
</VStack>
</Container>
</Box>
{/* 底部区域 */}
<Box
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 100%)"
py={{ base: 8, md: 12 }}
position="relative"
>
<Container maxW="7xl" position="relative" zIndex={1} px={containerPx}>
<VStack spacing={{ base: 4, md: 6 }} textAlign="center">
<Text color="whiteAlpha.600" fontSize={{ base: 'xs', md: 'sm' }}>
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={{ base: 2, md: 4 }} fontSize="xs" color="whiteAlpha.500" flexWrap="wrap" justify="center">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
color="whiteAlpha.500"
_hover={{ color: 'whiteAlpha.700' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}

View File

@@ -23,7 +23,6 @@ import {
StatArrow,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react';
import {
RepeatIcon,
@@ -493,26 +492,6 @@ export default function LimitAnalyse() {
</VStack>
</Box>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}

View File

@@ -48,7 +48,6 @@ import {
Progress,
Tag,
TagLabel,
Link,
Skeleton,
SkeletonText,
Popover,
@@ -578,7 +577,7 @@ const StockOverview = () => {
bgGradient={colorMode === 'dark' ? `linear(to-r, ${goldColor}, white)` : 'none'}
bgClip={colorMode === 'dark' ? 'text' : 'none'}
>
票概览中心
股中心
</Heading>
</HStack>
@@ -1055,26 +1054,6 @@ const StockOverview = () => {
</Box>
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
};

View File

@@ -31,7 +31,6 @@ import {
AlertIcon,
AlertTitle,
AlertDescription,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
@@ -56,7 +55,12 @@ export default function TradingSimulation() {
const { user, isAuthenticated } = useAuth();
const [activeTab, setActiveTab] = useState(0);
const [assetHistory, setAssetHistory] = useState([]); // 移到这里!
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 使用模拟账户管理 Hook
const {
account,
@@ -87,12 +91,20 @@ export default function TradingSimulation() {
// 调试:观察认证状态变化
useEffect(() => {
logger.debug('TradingSimulation', '组件挂载,认证状态检查', {
isAuthenticated,
userId: user?.id,
userName: user?.name
});
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免无限循环
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
logger.debug('TradingSimulation', '组件挂载,认证状态检查', {
isAuthenticated,
userId,
userName: user?.name
});
}
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId防重复通过 ref 判断
// 获取资产历史数据的 useEffect
useEffect(() => {
@@ -376,26 +388,6 @@ export default function TradingSimulation() {
)}
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}