Compare commits

...

58 Commits

Author SHA1 Message Date
zdl
ce46820105 feat: 优化社区动态新闻分页和预加载策略
## 主要改动

### 1. 修复分页显示问题
- 修复总页数计算错误(使用服务端 total 而非缓存 cachedCount)
- 修复目标页数据检查逻辑(排除 null 占位符)

### 2. 实现请求拆分策略 (Critical Fix)
- 将合并请求(per_page: 15)拆分为单页循环请求(per_page: 5)
- 解决后端无法处理动态 per_page 导致返回空数据的问题
- 后台预加载和显示 loading 两个场景均已拆分

### 3. 优化智能预加载逻辑
- 连续翻页(上/下页):预加载前后各 2 页
- 跳转翻页(点页码):只加载当前页
- 目标页已缓存时立即切换,后台静默预加载其他页

### 4. Redux 状态管理优化
- 添加 pageSize 参数用于正确计算索引
- 重写 reducer 插入逻辑(append/replace/jump 三种模式)
- 只在 append 模式去重,避免替换和跳页时数据丢失
- 修复 selector 计算有效数量(排除 null)

### 5. 修复 React Hook 规则违规
- 将所有 useColorModeValue 移至组件顶层
- 添加缺失的 HStack 导入

## 影响范围
- 仅影响社区页面动态新闻分页功能
- 无后端变更,向后兼容

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:43:54 +08:00
zdl
012c13c49a fix: 修复微信扫码登录后页面跳转问题
修改 iframe 显示条件,仅在 WAITING 状态时显示 iframe,
当状态变更为 SCANNED/AUTHORIZED 时立即移除 iframe,
防止微信页面执行父页面跳转操作。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:43:54 +08:00
zdl
0e9a0d9123 feat: 恢复bugfix 2025-11-04 11:43:54 +08:00
4f163af846 fix 2025-11-04 09:45:12 +08:00
zdl
ce495ed6fa feat: bugfix 2025-11-03 19:45:32 +08:00
zdl
0e66bb471f fix: 修复 PostHog 生产环境配置问题
## 问题描述
生产环境部署后,PostHog 只收到 localhost:3000 的错误报告,而不是生产环境的真实 URL。

## 根本原因
构建脚本未显式加载生产环境配置文件,导致 PostHog API Key 和 Host 配置未正确嵌入到打包文件中。

## 解决方案
1. 新增 `.env.production` 生产环境专用配置文件
   - 包含正确的 PostHog API Key 和 Host
   - 设置 REACT_APP_ENV=production
   - 禁用 Mock 数据 (REACT_APP_ENABLE_MOCK=false)
   - 配置生产 API 地址

2. 修改 package.json 构建脚本
   - 使用 env-cmd 显式加载 .env.production
   - 确保构建时环境变量正确嵌入

## 影响范围
-  生产环境构建: 现在会正确加载配置
-  PostHog 功能: 将使用正确的配置初始化
-  开发环境: 无影响,仍使用各自的环境文件
-  部署流程: 服务器构建时自动使用新配置

## 测试计划
1. 本地执行 npm run build 验证构建成功
2. 部署到生产环境
3. 验证 PostHog 后台收到正确的生产 URL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 18:42:58 +08:00
zdl
82cb0b4034 feat: bugfix 2025-11-03 18:26:59 +08:00
zdl
78e7001372 feat: bugfix 2025-11-03 18:20:57 +08:00
zdl
26ad017d32 feat: bugfix 2025-11-03 18:11:21 +08:00
zdl
fea0bc3bbe Merge branch 'feature_2025/1028_event' into feature
* feature_2025/1028_event: (107 commits)
  feat: 实现 Redux 全局状态管理事件关注功能
  feat: 添加mock接口
  feat: 单排/双排列表模式切换
  feat: bug修复
  fix: 修复 Mock 环境相关概念返回空结果问题
  refactor: 优化 StockChangeIndicators 颜色层次和视觉对比度
  feat: 统一事件详情和滚动列表的重要性颜色样式
  feat: 优化 EventScrollList 分页控制器位置和样式
  feat本次提交包含的优化
  fix: 完全移除 EventScrollList 顶部间距
  fix: 减少 EventScrollList 顶部间距
  fix: 修改 EventScrollList 左右箭头为翻页功能
  feat: 优化社区页面滚动和分页交互体验…)   ⎿  [feature_2025/1028_event 5dedbb3] feat: 优化社区页面滚动和分页交互体验       6 files changed, 1355 insertions(+), 49 deletions(-)       create mode 100644 docs/test-cases/Community351241265351235242346265213350257225347224250344276213.md
  fix: 修改相关概念组件以匹配真实API数据结构
  refactor: 移除 RelatedConcepts 组件中的 API_BASE_URL 配置
  feat: 增强历史事件对比卡片交互,支持点击跳转事件详情
  feat: 修复相关概念卡片跳转逻辑,支持跳转至概念中心
  feat: 优化股票卡片交互体验
  feat: 在 DynamicNewsCard 头部集成搜索和筛选功能
  feat(HistoricalEvents): 优化历史事件列表 UI 和相关股票弹窗
  ...
2025-11-03 17:41:28 +08:00
zdl
f17a8fbd87 feat: 实现 Redux 全局状态管理事件关注功能
本次提交实现了滚动列表和事件详情的关注按钮状态同步:

 Redux 状态管理
- communityDataSlice.js: 添加 eventFollowStatus state
- 新增 toggleEventFollow AsyncThunk(复用 EventList.js 逻辑)
- 新增 setEventFollowStatus reducer 和 selectEventFollowStatus selector

 组件集成
- DynamicNewsCard.js: 从 Redux 读取关注状态并传递给子组件
- EventScrollList.js: 接收并传递关注状态给事件卡片
- DynamicNewsDetailPanel.js: 移除本地 state,使用 Redux 状态

 Mock API 支持
- event.js: 添加 POST /api/events/:eventId/follow 处理器
- 返回 { is_following, follower_count } 模拟数据

 Bug 修复
- EventDetail/index.js: 添加 useRef 导入
- concept.js: 导出 generatePopularConcepts 函数
- event.js: 添加 /api/events/:eventId/concepts 处理器

功能:
- 点击滚动列表的关注按钮,详情面板的关注状态自动同步
- 点击详情面板的关注按钮,滚动列表的关注状态自动同步
- 关注人数实时更新
- 状态在整个应用中保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 17:40:09 +08:00
zdl
6a0a8e8e2b feat: 添加mock接口 2025-11-03 17:31:25 +08:00
zdl
8ebfad9992 feat: 单排/双排列表模式切换 2025-11-03 17:21:07 +08:00
zdl
c208ba36b7 feat: bug修复 2025-11-03 17:12:01 +08:00
zdl
b14eb175f5 fix: 修复 Mock 环境相关概念返回空结果问题
问题分析:
- Mock handler 的过滤逻辑过于严格
- 只保留概念名包含查询关键词的结果
- 导致大部分查询返回空数组

解决方案:
 移除字符串匹配过滤逻辑
- Mock 环境直接返回热门概念
- 模拟真实 API 的语义搜索行为
- 确保每次搜索都有结果展示

 添加详细调试日志
- RelatedConceptsSection 组件渲染日志
- useEffect 触发和参数日志
- 请求发送和响应详情
- 数据处理过程追踪

 完善 Mock 数据结构
- 添加 score, match_type, happened_times, stocks
- 支持详细卡片展示
- 数据结构与线上完全一致

修改文件:
- src/mocks/handlers/concept.js
- src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 16:40:25 +08:00
0d84ffe87f 修改总结 2025-11-03 16:10:35 +08:00
zdl
b95607e9b4 refactor: 优化 StockChangeIndicators 颜色层次和视觉对比度
优化:
- 背景色统一使用 50 最浅色 (red.50/orange.50/green.50/teal.50)
- 边框色根据涨跌幅大小动态调整 (100-200 级别)
- 确保背景 < 边框 < 文字的颜色深度层次
- 提升视觉对比度和可读性
- 更新注释说明颜色逻辑

修改文件:
- src/components/StockChangeIndicators.js

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 16:01:42 +08:00
zdl
462933f4af feat: 统一事件详情和滚动列表的重要性颜色样式
优化:
- 事件详情页面的重要性标签从固定橙色改为动态红色渐变
- 背景色使用 importance.bgColor (red.50)
- 文字和边框颜色使用 importance.badgeBg (red.800/600/500/400)
- 添加 2px 边框以保持视觉一致性
- 与滚动事件列表的重要性角标样式保持统一

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 15:59:12 +08:00
zdl
26dcfd061c feat: 优化 EventScrollList 分页控制器位置和样式
本次提交包含以下优化:

 分页控制器位置调整
- 从底部移至顶部右对齐
- 使用相对定位 (Flex justify="flex-end")
- 移除 CardBody 顶部 padding (pt={0})
- 确保分页控制器紧贴顶部,无任何间距

 箭头样式优化
- 调整箭头大小和颜色
- 使用毛玻璃效果背景
- 改善视觉层次和交互体验

修改文件:
- src/views/Community/components/DynamicNewsCard.js (CardBody pt={0})
- src/views/Community/components/DynamicNewsCard/EventScrollList.js (分页位置和箭头样式)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 15:56:19 +08:00
zdl
7e32dda2df feat本次提交包含的优化
 StockChangeIndicators 组件优化

  - 调整 padding 使布局更紧凑
  - 修复窄卡片中的折行问题
  - 自动根据内容调整宽度

   重要性等级视觉优化

  - 统一使用红色系(S→A→B→C:从深红到浅红)
  - 添加 badgeBg 字段支持新的角标样式

   DynamicNewsEventCard 卡片改版

  - 左上角矩形角标显示重要性(镂空边框样式)
  - 悬浮显示所有等级说明
  - 标题限制两行显示

   Mock 数据完整性

  - 添加缺失的 related_week_chg 字段
  - 确保三个涨跌幅指标数据完整
2025-11-03 15:38:30 +08:00
zdl
9274323151 fix: 完全移除 EventScrollList 顶部间距
问题:
- EventScrollList 顶部间距 (pt={2}, 8px) 仍然过大
- 用户期望事件列表紧贴搜索框,无顶部间距

修改:
- pt={2} 改为 pt={0}
- 顶部间距从 8px 完全移除为 0px
- 保持底部 pb={4} (16px) 和左右 px={2} (8px) 不变

视觉效果:
- EventScrollList 紧贴 CardHeader,更加紧凑
- 其他方向间距保持不变

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:41:05 +08:00
zdl
cedfd3978d fix: 减少 EventScrollList 顶部间距
问题:
- EventScrollList 的 Flex 容器设置了 py={4}(上下各 16px padding)
- 导致顶部间距过大,视觉不够紧凑

修改:
- py={4} 改为 pt={2} pb={4}
- 顶部间距从 16px 减少到 8px
- 保持底部 16px 间距,为滚动条留出足够空间

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:32:28 +08:00
zdl
89fe0cd10b fix: 修改 EventScrollList 左右箭头为翻页功能
问题:
- 左边箭头位置 (left: -4) 超出容器,看不到
- 右边箭头点击只是滚动 400px,而不是切换页面
- 用户期望左右箭头用于翻页,而不是横向滚动

修改内容:
1. 删除滚动相关函数和状态
   - 删除 scrollLeft()、scrollRight() 函数
   - 删除 handleScroll() 监听函数
   - 删除 showLeftArrow、showRightArrow state
   - 删除 useEffect 重置滚动位置逻辑
   - 移除 useState、useEffect 导入

2. 修改箭头功能从"滚动"改为"翻页"
   - 左箭头: onClick={scrollLeft} → onClick={() => onPageChange(currentPage - 1)}
   - 右箭头: onClick={scrollRight} → onClick={() => onPageChange(currentPage + 1)}

3. 修改箭头显隐逻辑为基于页码
   - 左箭头: showLeftArrow → currentPage > 1
   - 右箭头: showRightArrow → currentPage < totalPages

4. 优化箭头位置和样式
   - 位置: left/right: "-4" → "2" (在容器内部边缘)
   - 图标尺寸: boxSize={6} → boxSize={8}
   - 按钮尺寸: size="md" → size="lg"
   - 阴影: shadow="md" → shadow="lg"
   - 明确背景色: bg="blue.500"
   - 增强 hover 效果: 放大 scale(1.1) + 加深颜色
   - 更新说明文字: "向左/右滚动" → "上一页/下一页"

预期效果:
- 左箭头点击后加载上一页数据
- 右箭头点击后加载下一页数据
- 第1页时左箭头隐藏,最后一页时右箭头隐藏
- 箭头位置清晰可见,视觉效果突出

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:29:44 +08:00
zdl
d027071e98 feat: 优化社区页面滚动和分页交互体验…)
⎿  [feature_2025/1028_event 5dedbb3] feat: 优化社区页面滚动和分页交互体验
      6 files changed, 1355 insertions(+), 49 deletions(-)
      create mode 100644 docs/test-cases/Community351241265351235242346265213350257225347224250344276213.md
2025-11-03 14:24:41 +08:00
zdl
e31e4118a0 fix: 修改相关概念组件以匹配真实API数据结构
修改内容:
- SimpleConceptCard.js: 改用 concept.concept 和 concept.score 字段
- DetailedConceptCard.js: 改用 concept.concept、concept.score 和 concept.price_info.avg_change_pct
- RelatedConceptsSection/index.js: 导航时使用 concept.concept 字段
- events.js mock数据: 更新keywords生成函数,使用concept/score/price_info结构

数据结构变更:
- name → concept (概念名称)
- relevance (0-100) → score (0-1)
- avg_change_pct → price_info.avg_change_pct (嵌套结构)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:18:17 +08:00
zdl
5611c06991 refactor: 移除 RelatedConcepts 组件中的 API_BASE_URL 配置
移除硬编码的 API 基础地址配置,改为直接使用 API 路径:
- 删除 API_BASE_URL 常量定义
- 修改 fetch 请求直接使用 '/concept-api/search'
- 依赖项目的环境配置文件进行代理配置

优点:
- 代码更简洁,不需要环境判断
- 统一使用项目级别的代理配置
- 便于维护和部署

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 13:55:32 +08:00
zdl
784202025c feat: 增强历史事件对比卡片交互,支持点击跳转事件详情
功能新增:
- 点击事件卡片跳转到事件详情页(/event-detail/:eventId)
- 点击事件标题跳转到事件详情页(带下划线 hover 效果)
- "相关股票"按钮独立触发弹窗,不影响卡片跳转

组件修改:
- HistoricalEvents.js:
  * 导入 useNavigate hook 用于路由跳转
  * 添加 handleCardClick 函数处理跳转逻辑
  * 事件卡片添加 cursor="pointer" 和 onClick 事件
  * 优化卡片 hover 效果(阴影、边框色、上浮动画)
  * 标题添加独立的点击事件和下划线 hover 效果
  * "相关股票"按钮添加 stopPropagation 阻止事件冒泡

交互优化:
- 卡片 hover: boxShadow 从 md → lg,边框从 blue.300 → blue.400
- 卡片 hover: 添加 translateY(-2px) 上浮效果
- 标题 hover: 添加下划线提示可点击
- 光标样式: 卡片和标题都显示 pointer

事件冒泡控制:
- 标题点击: stopPropagation 后再触发跳转(保持一致性)
- 相关股票按钮: stopPropagation 防止触发卡片跳转
- 确保各个点击区域互不干扰

用户体验提升:
- 多种点击方式提供便利性(整个卡片、标题)
- 更明显的视觉反馈(hover 效果、光标变化)
- 精确的交互控制,避免误触发

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 13:08:07 +08:00
zdl
daf7372bab feat: 修复相关概念卡片跳转逻辑,支持跳转至概念中心
功能优化:
- 相关概念卡片点击跳转至概念中心(/concepts)并自动搜索该概念
- 概念相关股票支持点击跳转至公司详情页

组件修改:
- RelatedConceptsSection/index.js:
  * 修复 handleConceptClick 函数跳转路径
  * 从错误的 /concept/:name 改为正确的 /concepts?q=:name
  * 使用 encodeURIComponent 确保中文概念名称正确编码

- RelatedConceptsSection/ConceptStockItem.js:
  * 新增 handleStockClick 点击处理函数
  * 点击股票跳转至公司详情页(valuefrontier.cn/company)
  * 添加 hover 效果和过渡动画
  * 使用 stopPropagation 防止事件冒泡到概念卡片

跳转行为:
- 简单概念卡片(横向)→ 点击跳转到概念中心搜索结果页
- 详细概念卡片(展开后)→ 点击跳转到概念中心搜索结果页
- 概念相关股票 → 点击跳转到公司详情页(新标签页)

URL示例:
- 点击"人工智能"概念 → /concepts?q=人工智能
- 点击股票"000001.SZ" → valuefrontier.cn/company?scode=000001

用户体验提升:
- 概念卡片跳转逻辑符合用户预期
- 股票点击可查看公司详情,提供更多信息
- 事件冒泡控制正确,避免误触发

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:58:21 +08:00
zdl
7291777488 feat: 优化股票卡片交互体验
StockListItem 组件优化:
- 整个卡片可点击,点击后跳转到股票详情页(新标签页)
- 添加 cursor="pointer" 鼠标悬停提示
- 分时图/K线图区域点击时阻止事件冒泡,仅打开弹窗
- "查看"按钮、自选股按钮、展开/收起按钮点击时阻止冒泡

StockChartModal 组件修复:
- 修复 relation_desc 对象渲染错误
- 添加 getRelationDesc() 函数兼容对象和字符串格式
- 正确提取 {data: [...]} 结构中的文本内容

交互改进:
- 用户可点击卡片任意空白区域快速跳转
- 图表、按钮保持独立交互功能
- 提升用户操作便利性和体验流畅度

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:54:26 +08:00
zdl
92d6751529 feat: 在 DynamicNewsCard 头部集成搜索和筛选功能
功能新增:
- 将 UnifiedSearchBox 组件集成到 DynamicNewsCard 的 CardHeader 中
- 实现 DynamicNewsCard 和 EventTimelineCard 共享筛选状态
- 用户可在动态新闻区域直接进行搜索和筛选操作

组件修改:
- DynamicNewsCard.js:
  * 导入 UnifiedSearchBox 组件
  * 添加 filters, popularKeywords, onSearch, onSearchFocus 等 props
  * 在 CardHeader 内部渲染搜索框(标题下方,mt={4})
- Community/index.js:
  * 向 DynamicNewsCard 传递筛选状态和回调函数
  * filters 和 popularKeywords 数据传递
  * updateFilters 和 scrollToTimeline 回调传递

布局结构:
CardHeader
├─ 第一行:标题、徽章、更新时间
└─ 第二行:UnifiedSearchBox(搜索框 + 热门概念 + 筛选器)

状态管理:
- 使用共享的 filters 状态(来自 useEventFilters hook)
- 搜索操作通过 updateFilters 回调同步到父组件
- 两个组件的筛选条件保持一致

用户体验提升:
- 用户无需滚动到页面底部即可进行搜索
- 动态新闻区域功能更完整和独立
- 搜索结果在两个组件间同步显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:49:58 +08:00
zdl
95134d526d feat(HistoricalEvents): 优化历史事件列表 UI 和相关股票弹窗
主要改进:
1. 历史事件列表改为卡片式网格布局
   - 移除时间轴样式(垂直线 + 节点图标)
   - 使用 SimpleGrid 响应式布局(1列/2列/3列)
   - 卡片显示:事件名称、日期、相关度、重要性、描述
   - 点击"相关股票"按钮打开 Modal 弹窗

2. 历史事件对比默认展开
   - DynamicNewsDetailPanel: isHistoricalOpen 初始值改为 true
   - 用户打开事件详情面板时,历史事件对比区域默认展开

3. 相关股票弹窗改为卡片式布局
   - StocksList 组件从 Table 表格改为 SimpleGrid 卡片
   - 显示 6 个字段:代码、名称、板块、相关度、涨幅、关联原因
   - 关联原因支持展开/收起(startingHeight: 40px)
   - 响应式网格布局(base: 1列, md: 2列, lg: 3列)

4. 修复字段映射兼容性
   - 添加 getEventDate() 兼容多种日期字段
   - 添加 getEventContent() 兼容多种内容字段
   - 支持字段:event_date/created_at/date、content/description/summary
   - 添加 Debug 日志输出实际数据结构

5. 修复弹窗关闭问题
   - 添加 handleCloseModal() 同时清空两个状态
   - 使用条件渲染 {stocksModalOpen && <Modal>}
   - 关闭时完全卸载 Modal 组件,避免状态残留

技术细节:
- 移除未使用的导入(Table, Thead, Tbody, Tr, Th, Td 等)
- 新增工具函数:formatChange, getChangeColor, getCorrelationColor
- 卡片 hover 效果:boxShadow + borderColor 变化
- 涨跌幅颜色:红色(上涨)/ 绿色(下跌)
- 相关度颜色梯度:>=80% 红色, >=60% 橙色, <60% 绿色

代码统计:
- HistoricalEvents.js: -402 行, +344 行(净减少 58 行)
- 移除时间轴复杂逻辑,简化组件结构
- 提升代码可维护性和可读性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:41:02 +08:00
zdl
cc2777ae20 feat: 实现实时要闻服务端分页功能
功能新增:
- 实时要闻组件支持服务端分页,每次切换页码重新请求数据
- 分页控制器组件,支持数字页码、上下翻页、快速跳转
- Mock 数据量从 100 条增加到 200 条,支持分页测试

技术实现:

1. Redux 状态管理(communityDataSlice.js)
   - fetchDynamicNews 接收分页参数 { page, per_page }
   - 返回数据结构调整为 { events, pagination }
   - initialState 新增 dynamicNewsPagination 字段
   - Reducer 分别存储 events 和 pagination 信息
   - Selector 返回完整的 pagination 数据

2. 组件层(index.js → DynamicNewsCard → EventScrollList)
   - Community/index.js: 获取并传递 pagination 信息
   - DynamicNewsCard.js: 管理分页状态,触发服务端请求
   - EventScrollList.js: 接收服务端 totalPages,渲染当前页数据
   - 页码切换时自动选中第一个事件

3. 分页控制器(PaginationControl.js)
   - 精简版设计:移除首页/末页按钮
   - 上一页/下一页按钮,边界状态自动禁用
   - 智能页码列表(最多5个,使用省略号)
   - 输入框跳转功能,支持回车键
   - Toast 提示非法输入
   - 全部使用 xs 尺寸,紧凑布局

4. Mock 数据(events.js)
   - 总事件数从 100 增加到 200 条
   - 支持服务端分页测试(40 页 × 5 条/页)

分页流程:
1. 初始加载:请求 page=1, per_page=5
2. 切换页码:dispatch(fetchDynamicNews({ page: 2, per_page: 5 }))
3. 后端返回:{ events: [5条], pagination: { page, total, total_pages } }
4. 前端更新:显示新页面数据,更新分页控制器状态

UI 优化:
- 紧凑的分页控制器布局
- 移除冗余元素(首页/末页/总页数提示)
- xs 尺寸按钮,减少视觉负担
- 保留核心功能(翻页、页码、跳转)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:38:25 +08:00
zdl
39a2ccd53b refactor(stockSlice): 移除 LocalStorage 缓存层,简化为两级缓存架构 2025-11-03 11:58:39 +08:00
zdl
6160edf060 feat(DynamicNewsDetailPanel): 升级为实时数据,移除模拟数据生成 2025-11-03 11:57:04 +08:00
zdl
bdea4209b2 feat: 添加 EventScrollList.js 组件 2025-11-03 11:42:04 +08:00
zdl
6cde2175db feat: 实现实时要闻事件卡片点击高亮效果
功能新增:
- 点击事件卡片后显示高亮状态
- 当前选中的卡片有明显的视觉反馈

视觉效果:
- 选中状态:蓝色浅背景 (blue.50) + 蓝色粗边框 (2px, blue.500) + 大阴影 (lg)
- 未选中状态:原样式(白色/灰色交替背景 + 细边框 + 小阴影)
- 过渡动画:0.3s 平滑过渡
- 悬停效果:选中卡片悬停时边框变为 blue.600,阴影增强为 xl

技术实现:
1. DynamicNewsCard.js:
   - 传递 isSelected prop 给 DynamicNewsEventCard
   - 判断逻辑:isSelected={selectedEvent?.id === event.id}

2. DynamicNewsEventCard.js:
   - 添加 isSelected 参数(默认 false)
   - 根据 isSelected 动态调整 Card 样式:
     - 背景色:选中 blue.50 / 未选中 原样式
     - 边框:选中 2px blue.500 / 未选中 1px 原颜色
     - 阴影:选中 lg / 未选中 sm

用户体验提升:
- 清晰显示当前查看的事件
- 与下方详情面板形成呼应
- 视觉反馈明确,交互友好

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 11:28:03 +08:00
zdl
f432d72151 fix: 移除 DynamicNewsCard 点击事件时的弹窗触发
问题描述:
- 点击新闻卡片时,既更新了详情组件,又触发了不需要的弹窗
- 用户只希望更新下方的详情面板,不需要弹窗

解决方案:
- 移除 onEventClick 和 onTitleClick 中对父组件回调的调用
- 保留 setSelectedEvent 更新逻辑
- 详情面板仍然正常更新显示

修改位置:
- src/views/Community/components/DynamicNewsCard.js 第226-235行

交互效果:
- 点击新闻卡片 → 只更新下方的 DynamicNewsDetailPanel
- 不再触发任何额外的弹窗
- 保持内联详情面板显示方式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 11:19:10 +08:00
zdl
befa68cc51 feat: 接入真实数据 2025-11-03 10:06:48 +08:00
zdl
7ae4bc418f feat: 提取交易日期 2025-11-02 16:41:55 +08:00
zdl
0110dc2fdc feat: 添加滚动组件 2025-11-02 16:41:21 +08:00
zdl
e7e2b3bb11 feat: 提交迷你分时图组件 2025-11-02 16:38:44 +08:00
zdl
e22a39c5cd feat: 提交历史事件对比组件 2025-11-02 16:37:46 +08:00
zdl
3b8b749eb1 feat: 添加相关股票模块 2025-11-01 12:19:47 +08:00
zdl
571d5e68bc feat:删除不必要组件 2025-10-31 20:12:05 +08:00
zdl
933932b86d feat:添加mock数据 2025-10-31 20:11:50 +08:00
zdl
fc251ede05 feat: 添加相关概念组件 2025-10-31 20:08:53 +08:00
zdl
57c4c3c959 feat: 添加可折叠模块标题组件 2025-10-31 18:15:39 +08:00
zdl
e1e82555bf feat: 事件滑动面板添加 详情面板 2025-10-31 18:14:05 +08:00
zdl
b44a0ccd39 feat: 添加事件描述组件 2025-10-31 17:50:23 +08:00
zdl
2d936ca1c7 feat: UI调整 2025-10-31 16:29:11 +08:00
zdl
14db374820 style: 优化事件详情和涨跌幅指标的视觉效果
EventHeaderInfo 组件优化:
- "重要性:高"背景色改为浅杏黄色(yellow.100 → orange.50)
- 文字颜色改为深杏色(yellow.700 → orange.800)
- 视觉效果更柔和优雅,不刺眼

StockChangeIndicators 组件优化:
- 改用多颜色梯度(5级分级)
- 上涨:红色系(red.900/700/500)→ 橙色系(orange.600/400)
- 下跌:绿色系(green.900/700/500)→ 青色系(teal.600/400)
- 背景色和边框色跟随数字颜色
- 移除调试 console.log

视觉改进:
- 颜色分级更细腻,从3级增加到5级
- 引入橙色和青色让小幅和大幅波动有明显色系区别
- 5.7% 显示为深红色,1.7% 显示为橙色,视觉区分明显

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 16:00:37 +08:00
zdl
db472620f3 feat: 添加事件详情头部 2025-10-31 15:33:22 +08:00
zdl
37d98203a3 fix: 优化概念中心时间轴弹窗关闭行为,使用条件渲染
问题描述:
- 点击关闭按钮后,弹窗未完全关闭
- 可能存在 DOM 残留或状态问题

优化方案:
- 使用条件渲染替代 isOpen 属性控制
- 当状态为 false 时,Modal 组件完全从 DOM 中卸载
- 确保每次打开都是全新的状态

修改内容:
1. 主时间轴 Modal:添加 {isOpen && <Modal>...</Modal>} 条件渲染
2. 研报详情 Modal:添加 {isReportModalOpen && <Modal>...</Modal>} 条件渲染
3. 新闻详情 Modal:添加 {isNewsModalOpen && <Modal>...</Modal>} 条件渲染

优化效果:
- 弹窗关闭后组件完全卸载,避免残留
- 减少不必要的 DOM 节点,提升性能
- 每次打开都是全新的组件实例

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 15:05:15 +08:00
zdl
2420ff45a4 feat:暂时注释掉市场复盘 2025-10-31 15:01:53 +08:00
zdl
adaebbf800 fix: 修复概念中心历史时间轴"查看详情"按钮无响应问题
问题描述:
- 在历史时间轴弹窗中,点击新闻或研报的"查看详情"按钮无响应
- 导致用户无法查看新闻/研报的详细内容

问题根因:
- 在 onClick 事件处理函数中使用了未定义的变量 `date`
- 应该使用循环中的 `item.date` 变量
- 未定义的变量导致追踪函数报错,阻止了后续代码执行
- Modal 无法正常打开

修复内容:
- 第750行:trackNewsClicked(event, date) → trackNewsClicked(event, item.date)
- 第763行:trackReportClicked(event, date) → trackReportClicked(event, item.date)

影响范围:
- 概念中心历史时间轴功能
- 新闻和研报详情查看功能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 14:51:53 +08:00
zdl
9fd9fcb731 feat: 添加事件详情面板 2025-10-31 14:38:43 +08:00
zdl
c372832f1f feat: 新增实时要闻·动态追踪与市场复盘功能,优化导航体验
新增功能:
- 实时要闻·动态追踪横向滚动卡片(DynamicNewsCard)
- 动态新闻事件卡片组件(DynamicNewsEventCard)
- 市场复盘卡片组件(MarketReviewCard)
- 股票涨跌幅指标组件(StockChangeIndicators)
- 交易时间工具函数(tradingTimeUtils)
- Mock API 支持动态新闻数据生成

UI 优化:
- EventFollowButton 改用 react-icons 星星图标,实现真正的空心/实心效果
- 关注按钮添加半透明白色背景(whiteAlpha.500),悬停效果更明显
- 事件卡片标题添加右侧留白,防止关注按钮遮挡文字

性能优化:
- 禁用 Router v7_startTransition 特性,解决路由切换延迟 2 秒问题
- 调整导航菜单点击顺序(先跳转后关闭),提升响应速度

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 14:11:03 +08:00
zdl
5d8ad5e442 feat: bugfix 2025-10-31 10:33:53 +08:00
81 changed files with 5737 additions and 4414 deletions

42
.env.production Normal file
View File

@@ -0,0 +1,42 @@
# ========================================
# 生产环境配置
# ========================================
# 使用方式: npm run build
#
# 工作原理:
# 1. 此文件专门用于生产环境构建
# 2. 构建时会将环境变量嵌入到打包文件中
# 3. 确保 PostHog 等服务使用正确的生产配置
# ========================================
# 环境标识
REACT_APP_ENV=production
NODE_ENV=production
# Mock 配置(生产环境禁用 Mock
REACT_APP_ENABLE_MOCK=false
# 后端 API 地址(生产环境)
REACT_APP_API_URL=http://49.232.185.254:5001
# PostHog 分析配置(生产环境)
# PostHog API Key从 PostHog 项目设置中获取)
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
# PostHog API Host使用 PostHog Cloud
REACT_APP_POSTHOG_HOST=https://app.posthog.com
# 启用会话录制Session Recording用于回放用户操作、排查问题
REACT_APP_ENABLE_SESSION_RECORDING=true
# React 构建优化配置
# 禁用 source map 生成(生产环境不需要,提升打包速度和安全性)
GENERATE_SOURCEMAP=false
# 跳过预检查(加快启动速度)
SKIP_PREFLIGHT_CHECK=true
# 禁用 ESLint 检查(生产构建时不需要)
DISABLE_ESLINT_PLUGIN=true
# TypeScript 编译错误时继续
TSC_COMPILE_ON_ERROR=true
# 图片内联大小限制
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096

133
CLAUDE.md
View File

@@ -4,40 +4,61 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a hybrid React dashboard application with a Flask/Python backend. The project is built on the Argon Dashboard Chakra PRO template and includes financial/trading analysis features.
This is a hybrid React dashboard application with a Flask/Python backend for financial/trading analysis. Built on the Argon Dashboard Chakra PRO template with extensive customization.
### Frontend (React + Chakra UI)
- **Framework**: React 18.3.1 with Chakra UI 2.8.2
- **State Management**: Redux Toolkit (@reduxjs/toolkit)
- **Routing**: React Router DOM v6 with lazy loading for code splitting
- **Styling**: Tailwind CSS + custom Chakra theme
- **Build Tool**: React Scripts with custom Gulp tasks
- **Charts**: ApexCharts, ECharts, and custom visualization components
- **Build Tool**: CRACO (Create React App Configuration Override) with custom webpack optimizations
- **Charts**: ApexCharts, ECharts, Recharts, D3
- **UI Components**: Ant Design (antd) alongside Chakra UI
- **Other Libraries**: Three.js (@react-three), FullCalendar, Leaflet maps
### Backend (Flask/Python)
- **Framework**: Flask with SQLAlchemy ORM
- **Database**: ClickHouse for analytics + MySQL/PostgreSQL
- **Features**: Real-time data processing, trading analysis, user authentication
- **Task Queue**: Celery for background processing
- **Database**: ClickHouse for analytics queries + MySQL/PostgreSQL
- **Real-time**: Flask-SocketIO for WebSocket connections
- **Task Queue**: Celery with Redis for background processing
- **External APIs**: Tencent Cloud SMS, WeChat Pay integration
## Development Commands
### Frontend Development
```bash
npm start # Start development server (port 3000, proxies to localhost:5001)
npm run build # Production build with license headers
npm test # Run React test suite
npm run lint:check # Check ESLint rules
npm run lint:fix # Auto-fix ESLint issues
npm run install:clean # Clean install (removes node_modules and package-lock)
npm start # Start with mock data (.env.mock), proxies to localhost:5001
npm run start:real # Start with real backend (.env.local)
npm run start:dev # Start with development config (.env.development)
npm run start:test # Starts both backend (app_2.py) and frontend (.env.test) concurrently
npm run dev # Alias for 'npm start'
npm run backend # Start Flask server only (python app_2.py)
npm run build # Production build with Gulp license headers
npm run build:analyze # Build with webpack bundle analyzer
npm test # Run React test suite with CRACO
npm run lint:check # Check ESLint rules (exits 0)
npm run lint:fix # Auto-fix ESLint issues
npm run clean # Remove node_modules and package-lock.json
npm run reinstall # Clean install (runs clean + install)
```
### Backend Development
```bash
python app_2.py # Start Flask server (main backend)
python simulation_background_processor.py # Background data processor
python app.py # Main Flask server (newer version)
python app_2.py # Flask server (appears to be current main)
python simulation_background_processor.py # Background data processor for simulations
```
### Deployment
```bash
npm run deploy # Executes scripts/deploy-from-local.sh
npm run deploy:setup # Setup deployment (scripts/setup-deployment.sh)
npm run rollback # Rollback deployment (scripts/rollback-from-local.sh)
```
### Python Dependencies
Install from requirements.txt:
```bash
pip install -r requirements.txt
```
@@ -45,47 +66,69 @@ pip install -r requirements.txt
## Architecture
### Frontend Structure
- `src/layouts/` - Main layout components (Admin, Auth, Home)
- `src/views/` - Page components organized by feature (Dashboard, Company, Community, etc.)
- `src/components/` - Reusable UI components (Charts, Cards, Buttons, etc.)
- `src/theme/` - Chakra UI theme customization
- `src/routes.js` - Application routing configuration
- `src/contexts/` - React context providers
- `src/services/` - API service layer
- **src/App.js** - Main application entry with route definitions (routing moved from src/routes.js)
- **src/layouts/** - Layout wrappers (Auth, Home, MainLayout)
- **src/views/** - Page components (Community, Company, TradingSimulation, etc.)
- **src/components/** - Reusable UI components
- **src/contexts/** - React contexts (AuthContext, NotificationContext, IndustryContext)
- **src/store/** - Redux store with slices (posthogSlice, etc.)
- **src/services/** - API service layer
- **src/theme/** - Chakra UI theme customization
- **src/mocks/** - MSW (Mock Service Worker) handlers for development
- src/mocks/handlers/ - Request handlers by domain
- src/mocks/data/ - Mock data files
- src/mocks/browser.js - MSW browser setup
### Backend Structure
- `app_2.py` - Main Flask application with routes and business logic
- `simulation_background_processor.py` - Background data processing service
- `wechat_pay.py` / `wechat_pay_config.py` - Payment integration
- `tdays.csv` - Trading days data
- **app.py / app_2.py** - Main Flask application with routes, authentication, and business logic
- **simulation_background_processor.py** - Background processor for trading simulations
- **wechat_pay.py / wechat_pay_config.py** - WeChat payment integration
- **concept_api.py** - API for concept/industry analysis
- **tdays.csv** - Trading days calendar data (loaded into memory at startup)
### Key Integrations
- ClickHouse for high-performance analytics queries
- Celery + Redis for background task processing
- Flask-SocketIO for real-time data updates
- Tencent Cloud services (SMS, etc.)
- WeChat Pay integration
- **ClickHouse** - High-performance analytics queries
- **Celery + Redis** - Background task processing
- **Flask-SocketIO** - Real-time data updates via WebSocket
- **Tencent Cloud** - SMS services
- **WeChat Pay** - Payment processing
- **PostHog** - Analytics (initialized in Redux)
- **MSW** - API mocking for development/testing
### Routing & Code Splitting
- Routing is defined in **src/App.js** (not src/routes.js - that file is deprecated)
- Heavy components use React.lazy() for code splitting (Community, TradingSimulation, etc.)
- Protected routes use ProtectedRoute and ProtectedRouteRedirect components
## Configuration
### Proxy Setup
The React dev server proxies API calls to `http://localhost:5001` (see package.json).
### Environment Files
- `.env` - Environment variables for both frontend and backend
Multiple environment configurations available:
- **.env.mock** - Mock data mode (default for `npm start`)
- **.env.local** - Real backend connection
- **.env.development** - Development environment
- **.env.test** - Test environment
### Build Process
The build process includes custom Gulp tasks that add Creative Tim license headers to JS, CSS, and HTML files.
### Build Configuration (craco.config.js)
- **Webpack caching**: Filesystem cache for faster rebuilds (50-80% improvement)
- **Code splitting**: Aggressive chunk splitting by library (react-vendor, charts-lib, chakra-ui, antd-lib, three-lib, etc.)
- **Path aliases**: `@` → src/, `@components` → src/components/, `@views` → src/views/, `@assets` → src/assets/, `@contexts` → src/contexts/
- **Optimizations**: ESLint plugin removed from build for speed, Babel caching enabled, moment locale stripping
- **Source maps**: Disabled in production, eval-cheap-module-source-map in development
- **Dev server proxy**: `/api` requests proxy to http://49.232.185.254:5001
### Styling Architecture
- Tailwind CSS for utility classes
- Custom Chakra UI theme with extended color palette
- Component-specific SCSS files in `src/assets/scss/`
### Important Build Notes
- Uses NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' for Node compatibility
- Gulp task adds Creative Tim license headers post-build
- Bundle analyzer available via `ANALYZE=true npm run build:analyze`
- Pre-build: kills any process on port 3000
## Testing
- React Testing Library setup for frontend components
- Test command: `npm test`
- **React Testing Library** for component tests
- **MSW** (Mock Service Worker) for API mocking during tests
- Run tests: `npm test`
## Deployment
- Build: `npm run build`
- Deploy: `npm run deploy` (builds the project)
- Deployment scripts in **scripts/** directory
- Build output processed by Gulp for licensing
- Supports rollback via scripts/rollback-from-local.sh

63
app.py
View File

@@ -2602,13 +2602,9 @@ def get_wechat_qrcode():
# 生成唯一state参数
state = uuid.uuid4().hex
print(f"🆕 [QRCODE] 生成新的微信二维码, state={state[:8]}...")
# URL编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
print(f"🔗 [QRCODE] 回调地址: {WECHAT_REDIRECT_URI}")
# 构建微信授权URL
wechat_auth_url = (
f"https://open.weixin.qq.com/connect/qrconnect?"
@@ -2626,8 +2622,6 @@ def get_wechat_qrcode():
'wechat_unionid': None
}
print(f"✅ [QRCODE] session 已存储, 当前总数: {len(wechat_qr_sessions)}")
return jsonify({"code":0,
"data":
{
@@ -2691,8 +2685,6 @@ def check_wechat_scan():
del wechat_qr_sessions[session_id]
return jsonify({'status': 'expired'}), 200
print(f"📡 [CHECK] session_id: {session_id[:8]}..., status: {session['status']}, user_info: {session.get('user_info')}")
return jsonify({
'status': session['status'],
'user_info': session.get('user_info'),
@@ -2751,17 +2743,12 @@ def wechat_callback():
# 验证state
if state not in wechat_qr_sessions:
print(f"❌ [CALLBACK] state 不在 wechat_qr_sessions 中: {state[:8]}...")
print(f" 当前 sessions: {list(wechat_qr_sessions.keys())}")
return redirect('/auth/signin?error=session_expired')
session_data = wechat_qr_sessions[state]
print(f"✅ [CALLBACK] 找到 session_data, mode={session_data.get('mode')}")
# 检查过期
if time.time() > session_data['expires']:
print(f"❌ [CALLBACK] session 已过期")
del wechat_qr_sessions[state]
return redirect('/auth/signin?error=session_expired')
@@ -2790,8 +2777,6 @@ def wechat_callback():
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
return redirect('/auth/signin?error=userinfo_failed')
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
# 查找或创建用户 / 或处理绑定
openid = token_data['openid']
unionid = user_info.get('unionid') or token_data.get('unionid')
@@ -2842,8 +2827,7 @@ def wechat_callback():
user = User.query.filter_by(wechat_open_id=openid).first()
if not user:
# 创建新用户(自动注册)
is_new_user = True
# 创建新用户
# 先清理微信昵称
raw_nickname = user_info.get('nickname', '微信用户')
# 创建临时用户实例以使用清理方法
@@ -2893,22 +2877,8 @@ def wechat_callback():
session_item['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
return '''
<html>
<head><title>授权成功</title></head>
<body>
<h2>微信授权成功</h2>
<p>请返回原页面继续操作...</p>
<script>
// 尝试关闭窗口(如果是弹窗的话)
setTimeout(function() {
window.close();
}, 1000);
</script>
</body>
</html>
''', 200
# 直接跳转到首页
return redirect('/home')
except Exception as e:
print(f"❌ 微信登录失败: {e}")
@@ -2934,16 +2904,16 @@ def login_with_wechat():
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
# 验证session
wechat_session = wechat_qr_sessions.get(session_id)
if not wechat_session:
session = wechat_qr_sessions.get(session_id)
if not session:
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
# 检查session状态
if wechat_session['status'] not in ['login_success', 'register_success']:
if session['status'] not in ['login_ready', 'register_ready']:
return jsonify({'success': False, 'error': '会话状态无效'}), 400
# 检查是否有用户信息
user_info = wechat_session.get('user_info')
user_info = session.get('user_info')
if not user_info or not user_info.get('user_id'):
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
@@ -2955,33 +2925,18 @@ def login_with_wechat():
# 更新最后登录时间
user.update_last_seen()
# 设置 Flask session
session.permanent = True
session['user_id'] = user.id
session['username'] = user.username
session['logged_in'] = True
session['wechat_login'] = True # 标记是微信登录
# Flask-Login 登录
login_user(user, remember=True)
# 判断是否为新用户
is_new_user = user_info.get('is_new_user', False)
# 清除 wechat_qr_sessions
# 清除session
del wechat_qr_sessions[session_id]
# 生成登录响应
response_data = {
'success': True,
'message': '注册成功' if is_new_user else '登录成功',
'isNewUser': is_new_user,
'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功',
'user': {
'id': user.id,
'username': user.username,
'nickname': user.nickname or user.username,
'email': user.email,
'phone': user.phone,
'avatar_url': user.avatar_url,
'has_wechat': True,
'wechat_open_id': user.wechat_open_id,

View File

@@ -244,6 +244,13 @@ module.exports = {
secure: false,
logLevel: 'debug',
},
'/concept-api': {
target: 'http://49.232.185.254:6801',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
},
},
};

View File

@@ -101,7 +101,7 @@
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
"dev": "npm start",
"backend": "python app_2.py",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",
"eject": "react-scripts eject",
@@ -132,7 +132,6 @@
"prettier": "2.2.1",
"react-error-overlay": "6.0.9",
"sharp": "^0.34.4",
"tailwindcss": "^3.4.17",
"ts-node": "^10.9.2",
"webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0"

View File

@@ -501,26 +501,26 @@ export default function WechatRegister() {
bg="gray.50"
boxShadow="sm" // ✅ 添加轻微阴影
>
{wechatStatus !== WECHAT_STATUS.NONE ? (
{wechatStatus === WECHAT_STATUS.WAITING ? (
/* 已获取二维码显示iframe */
<iframe
src={wechatAuthUrl}
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
scrolling="no" // ✅ 新增:禁止滚动
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
style={{
border: 'none',
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transformOrigin: 'top left',
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -12,12 +12,12 @@ import {
Text,
Flex,
Badge,
useColorModeValue,
useDisclosure
useColorModeValue
} from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
/**
* 桌面版主导航菜单组件
@@ -37,11 +37,11 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
// 🎯 初始化导航埋点Hook
const navEvents = useNavigationEvents({ component: 'top_nav' });
// 🎯 为每个菜单创建独立的 useDisclosure Hook
const { isOpen: isHighFreqOpen, onOpen: onHighFreqOpen, onClose: onHighFreqClose } = useDisclosure();
const { isOpen: isMarketReviewOpen, onOpen: onMarketReviewOpen, onClose: onMarketReviewClose } = useDisclosure();
const { isOpen: isAgentCommunityOpen, onOpen: onAgentCommunityOpen, onClose: onAgentCommunityClose } = useDisclosure();
const { isOpen: isContactUsOpen, onOpen: onContactUsOpen, onClose: onContactUsClose } = useDisclosure();
// 🎯 为每个菜单创建延迟关闭控制200ms 延迟)
const highFreqMenu = useDelayedMenu({ closeDelay: 200 });
const marketReviewMenu = useDelayedMenu({ closeDelay: 200 });
const agentCommunityMenu = useDelayedMenu({ closeDelay: 200 });
const contactUsMenu = useDelayedMenu({ closeDelay: 200 });
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
@@ -53,7 +53,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
return (
<HStack spacing={8}>
{/* 高频跟踪 */}
<Menu isOpen={isHighFreqOpen} onClose={onHighFreqClose}>
<Menu isOpen={highFreqMenu.isOpen} onClose={highFreqMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
@@ -64,17 +64,24 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={onHighFreqOpen}
onMouseLeave={onHighFreqClose}
onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave}
onClick={highFreqMenu.handleClick}
>
高频跟踪
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen} onMouseLeave={onHighFreqClose}>
<MenuList
minW="260px"
p={2}
onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
navigate('/community');
highFreqMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
@@ -95,6 +102,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
navigate('/concepts');
highFreqMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
@@ -111,7 +119,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* 行情复盘 */}
<Menu isOpen={isMarketReviewOpen} onClose={onMarketReviewClose}>
<Menu isOpen={marketReviewMenu.isOpen} onClose={marketReviewMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
@@ -122,14 +130,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={onMarketReviewOpen}
onMouseLeave={onMarketReviewClose}
onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave}
onClick={marketReviewMenu.handleClick}
>
行情复盘
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen} onMouseLeave={onMarketReviewClose}>
<MenuList
minW="260px"
p={2}
onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave}
>
<MenuItem
onClick={() => navigate('/limit-analyse')}
onClick={() => {
navigate('/limit-analyse');
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
@@ -142,7 +159,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
onClick={() => {
navigate('/stocks');
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
@@ -155,7 +175,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
onClick={() => {
navigate('/trading-simulation');
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
@@ -171,17 +194,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* AGENT社群 */}
<Menu isOpen={isAgentCommunityOpen} onClose={onAgentCommunityClose}>
<Menu isOpen={agentCommunityMenu.isOpen} onClose={agentCommunityMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
onMouseEnter={onAgentCommunityOpen}
onMouseLeave={onAgentCommunityClose}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}
>
AGENT社群
</MenuButton>
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen} onMouseLeave={onAgentCommunityClose}>
<MenuList
minW="300px"
p={4}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
>
<MenuItem
isDisabled
cursor="not-allowed"
@@ -200,17 +229,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* 联系我们 */}
<Menu isOpen={isContactUsOpen} onClose={onContactUsClose}>
<Menu isOpen={contactUsMenu.isOpen} onClose={contactUsMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
onMouseEnter={onContactUsOpen}
onMouseLeave={onContactUsClose}
onMouseEnter={contactUsMenu.handleMouseEnter}
onMouseLeave={contactUsMenu.handleMouseLeave}
onClick={contactUsMenu.handleClick}
>
联系我们
</MenuButton>
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen} onMouseLeave={onContactUsClose}>
<MenuList
minW="260px"
p={4}
onMouseEnter={contactUsMenu.handleMouseEnter}
onMouseLeave={contactUsMenu.handleMouseLeave}
>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>

View File

@@ -12,11 +12,11 @@ import {
Text,
Flex,
HStack,
Badge,
useDisclosure
Badge
} from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
/**
* 平板版"更多"下拉菜单组件
@@ -30,8 +30,8 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// 🎯 为"更多"菜单创建 useDisclosure Hook
const { isOpen, onOpen, onClose } = useDisclosure();
// 🎯 使用延迟关闭菜单控制
const moreMenu = useDelayedMenu({ closeDelay: 200 });
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
@@ -41,22 +41,31 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
if (!isAuthenticated || !user) return null;
return (
<Menu isOpen={isOpen} onClose={onClose}>
<Menu isOpen={moreMenu.isOpen} onClose={moreMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
fontWeight="medium"
onMouseEnter={onOpen}
onMouseLeave={onClose}
onMouseEnter={moreMenu.handleMouseEnter}
onMouseLeave={moreMenu.handleMouseLeave}
onClick={moreMenu.handleClick}
>
更多
</MenuButton>
<MenuList minW="300px" p={2} onMouseEnter={onOpen} onMouseLeave={onClose}>
<MenuList
minW="300px"
p={2}
onMouseEnter={moreMenu.handleMouseEnter}
onMouseLeave={moreMenu.handleMouseLeave}
>
{/* 高频跟踪组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
<MenuItem
onClick={() => navigate('/community')}
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/community');
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
@@ -69,7 +78,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/concepts')}
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/concepts');
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
>
@@ -84,7 +96,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
{/* 行情复盘组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
<MenuItem
onClick={() => navigate('/limit-analyse')}
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/limit-analyse');
}}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
>
@@ -94,7 +109,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/stocks');
}}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
>
@@ -104,7 +122,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/trading-simulation');
}}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
>

View File

@@ -57,7 +57,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
>
个人中心
</MenuButton>
<MenuList onMouseEnter={onOpen} onMouseLeave={onClose}>
<MenuList onMouseEnter={onOpen}>
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
@@ -71,24 +71,36 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
</Box>
{/* 前往个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
<MenuItem icon={<FiHome />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/center');
}}>
前往个人中心
</MenuItem>
<MenuDivider />
{/* 账户管理组 */}
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
<MenuItem icon={<FiUser />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/profile');
}}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
<MenuItem icon={<FiSettings />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/settings');
}}>
账户设置
</MenuItem>
<MenuDivider />
{/* 功能入口组 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
<MenuItem icon={<FaCrown />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/pages/account/subscription');
}}>
订阅管理
</MenuItem>

View File

@@ -0,0 +1,168 @@
// src/components/StockChangeIndicators.js
// 股票涨跌幅指标组件(通用)
import React from 'react';
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
/**
* 股票涨跌幅指标组件3分天下布局
* @param {Object} props
* @param {number} props.avgChange - 平均涨跌幅
* @param {number} props.maxChange - 最大涨跌幅
* @param {number} props.weekChange - 周涨跌幅
*/
const StockChangeIndicators = ({
avgChange,
maxChange,
weekChange,
}) => {
// 根据涨跌幅获取数字颜色多颜色梯度5级分级
const getNumberColor = (value) => {
if (value == null) {
return useColorModeValue('gray.700', 'gray.400');
}
// 0值使用中性灰色
if (value === 0) {
return 'gray.700';
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨:红色系 → 橙色系
if (absValue >= 10) return 'red.900'; // 10%以上:最深红
if (absValue >= 5) return 'red.700'; // 5-10%:深红
if (absValue >= 3) return 'red.500'; // 3-5%:中红
if (absValue >= 1) return 'orange.600'; // 1-3%:橙色
return 'orange.400'; // 0-1%:浅橙
} else {
// 下跌:绿色系 → 青色系
if (absValue >= 10) return 'green.900'; // -10%以下:最深绿
if (absValue >= 5) return 'green.700'; // -10% ~ -5%:深绿
if (absValue >= 3) return 'green.500'; // -5% ~ -3%:中绿
if (absValue >= 1) return 'teal.600'; // -3% ~ -1%:青色
return 'teal.400'; // -1% ~ 0%:浅青
}
};
// 根据涨跌幅获取背景色(永远比文字色浅)
const getBgColor = (value) => {
if (value == null) {
return useColorModeValue('gray.50', 'gray.800');
}
// 0值使用中性灰色背景
if (value === 0) {
return useColorModeValue('gray.50', 'gray.800');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨背景:红色系 → 橙色系(统一使用 50 最浅色)
if (absValue >= 10) return useColorModeValue('red.50', 'red.900');
if (absValue >= 5) return useColorModeValue('red.50', 'red.900');
if (absValue >= 3) return useColorModeValue('red.50', 'red.900');
if (absValue >= 1) return useColorModeValue('orange.50', 'orange.900');
return useColorModeValue('orange.50', 'orange.900');
} else {
// 下跌背景:绿色系 → 青色系(统一使用 50 最浅色)
if (absValue >= 10) return useColorModeValue('green.50', 'green.900');
if (absValue >= 5) return useColorModeValue('green.50', 'green.900');
if (absValue >= 3) return useColorModeValue('green.50', 'green.900');
if (absValue >= 1) return useColorModeValue('teal.50', 'teal.900');
return useColorModeValue('teal.50', 'teal.900');
}
};
// 根据涨跌幅获取边框色(比背景深,比文字浅)
const getBorderColor = (value) => {
if (value == null) {
return useColorModeValue('gray.200', 'gray.700');
}
// 0值使用中性灰色边框
if (value === 0) {
return useColorModeValue('gray.200', 'gray.700');
}
const absValue = Math.abs(value);
const isPositive = value > 0;
if (isPositive) {
// 上涨边框:红色系 → 橙色系(跟随文字深浅)
if (absValue >= 10) return useColorModeValue('red.200', 'red.800'); // 文字 red.900
if (absValue >= 5) return useColorModeValue('red.200', 'red.700'); // 文字 red.700
if (absValue >= 3) return useColorModeValue('red.100', 'red.600'); // 文字 red.500
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700'); // 文字 orange.600
return useColorModeValue('orange.100', 'orange.600'); // 文字 orange.400
} else {
// 下跌边框:绿色系 → 青色系(跟随文字深浅)
if (absValue >= 10) return useColorModeValue('green.200', 'green.800'); // 文字 green.900
if (absValue >= 5) return useColorModeValue('green.200', 'green.700'); // 文字 green.700
if (absValue >= 3) return useColorModeValue('green.100', 'green.600'); // 文字 green.500
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700'); // 文字 teal.600
return useColorModeValue('teal.100', 'teal.600'); // 文字 teal.400
}
};
// 渲染单个指标
const renderIndicator = (label, value) => {
if (value == null) return null;
const sign = value > 0 ? '+' : '';
// 0值显示为 "0",其他值显示一位小数
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(1);
const numberColor = getNumberColor(value);
const bgColor = getBgColor(value);
const borderColor = getBorderColor(value);
const labelColor = useColorModeValue('gray.700', 'gray.400');
return (
<Box
bg={bgColor}
borderWidth="2px"
borderColor={borderColor}
borderRadius="md"
px={1.5}
py={0.5}
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontSize="xs" lineHeight="1.2">
<Text as="span" color={labelColor}>
{label}
</Text>
<Text as="span" color={labelColor}>
{sign}
</Text>
<Text as="span" fontWeight="bold" color={numberColor} fontSize="sm">
{value < 0 ? '-' : ''}{numStr}
</Text>
<Text as="span" color={labelColor}>
%
</Text>
</Text>
</Box>
);
};
// 如果没有任何数据,不渲染
if (avgChange == null && maxChange == null && weekChange == null) {
return null;
}
return (
<Flex width="100%" justify="space-between" align="center" gap={1}>
{renderIndicator('平均 ', avgChange)}
{renderIndicator('最大 ', maxChange)}
{renderIndicator('周涨 ', weekChange)}
</Flex>
);
};
export default StockChangeIndicators;

View File

@@ -23,6 +23,25 @@ const StockChartModal = ({
const [chartData, setChartData] = useState(null);
const [preloadedData, setPreloadedData] = useState({});
// 处理关联描述(兼容对象和字符串格式)
const getRelationDesc = () => {
const relationDesc = stock?.relation_desc;
if (!relationDesc) return null;
if (typeof relationDesc === 'string') {
return relationDesc;
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
return relationDesc.data
.map(item => item.query_part || item.sentences || '')
.filter(s => s)
.join('') || null;
}
return null;
};
// 预加载数据
const preloadData = async (type) => {
if (!stock || preloadedData[type]) return;
@@ -539,10 +558,10 @@ const StockChartModal = ({
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
</Box>
{stock?.relation_desc && (
{getRelationDesc() && (
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
<Text fontSize="sm" color="gray.600">{getRelationDesc()}</Text>
</Box>
)}

View File

@@ -15,47 +15,55 @@ import {
export const IMPORTANCE_LEVELS = {
'S': {
level: 'S',
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
color: 'red.800',
bgColor: 'red.50',
borderColor: 'red.200',
colorScheme: 'red',
badgeBg: 'red.800', // 角标边框和文字颜色 - 极深红色
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
dotBg: 'red.800',
description: '重大事件,市场影响深远',
antdColor: '#722ed1', // 对应 Ant Design 的紫色
antdColor: '#cf1322',
},
'A': {
level: 'A',
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
colorScheme: 'red',
badgeBg: 'red.600', // 角标边框和文字颜色 - 深红色
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
dotBg: 'red.600',
description: '重要事件,影响较大',
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
antdColor: '#ff4d4f',
},
'B': {
level: 'B',
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
color: 'red.500',
bgColor: 'red.50',
borderColor: 'red.100',
colorScheme: 'red',
badgeBg: 'red.500', // 角标边框和文字颜色 - 中红色
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
dotBg: 'red.500',
description: '普通事件,有一定影响',
antdColor: '#faad14', // 对应 Ant Design 的橙色
antdColor: '#ff7875',
},
'C': {
level: 'C',
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
color: 'red.400',
bgColor: 'red.50',
borderColor: 'red.100',
colorScheme: 'red',
badgeBg: 'red.400', // 角标边框和文字颜色 - 浅红色
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
dotBg: 'red.400',
description: '参考事件,影响有限',
antdColor: '#52c41a', // 对应 Ant Design 的绿色
antdColor: '#ffa39e',
}
};

142
src/hooks/useDelayedMenu.js Normal file
View File

@@ -0,0 +1,142 @@
// src/hooks/useDelayedMenu.js
// 导航菜单延迟关闭 Hook - 优化 hover 和 click 交互体验
import { useState, useRef, useCallback } from 'react';
/**
* 自定义 Hook提供带延迟关闭功能的菜单控制
*
* 解决问题:
* 1. 用户快速移动鼠标导致菜单意外关闭
* 2. Hover 和 Click 状态冲突
* 3. 从 MenuButton 移动到 MenuList 时菜单闪烁
*
* 功能特性:
* - ✅ Hover 进入:立即打开菜单
* - ✅ Hover 离开:延迟关闭(默认 200ms
* - ✅ Click 切换:支持点击切换打开/关闭状态
* - ✅ 智能取消:再次 hover 进入时取消关闭定时器
*
* @param {Object} options - 配置选项
* @param {number} options.closeDelay - 延迟关闭时间(毫秒),默认 200ms
* @returns {Object} 菜单控制对象
*/
export function useDelayedMenu({ closeDelay = 200 } = {}) {
const [isOpen, setIsOpen] = useState(false);
const closeTimerRef = useRef(null);
const isClickedRef = useRef(false); // 追踪是否通过点击打开
/**
* 打开菜单
* - 立即打开,无延迟
* - 清除任何待执行的关闭定时器
*/
const onOpen = useCallback(() => {
// 清除待执行的关闭定时器
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsOpen(true);
}, []);
/**
* 延迟关闭菜单
* - 设置定时器,延迟后关闭
* - 如果在延迟期间再次 hover 进入,会被 onOpen 取消
*/
const onDelayedClose = useCallback(() => {
// 如果是点击打开的hover 离开时不自动关闭
if (isClickedRef.current) {
return;
}
// 清除之前的定时器(防止重复设置)
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
// 设置延迟关闭定时器
closeTimerRef.current = setTimeout(() => {
setIsOpen(false);
closeTimerRef.current = null;
}, closeDelay);
}, [closeDelay]);
/**
* 立即关闭菜单
* - 无延迟,立即关闭
* - 清除所有定时器和状态标记
*/
const onClose = useCallback(() => {
// 清除定时器
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsOpen(false);
isClickedRef.current = false;
}, []);
/**
* 切换菜单状态(用于点击)
* - 如果关闭 → 打开,并标记为点击打开
* - 如果打开 → 关闭,并清除点击标记
*/
const onToggle = useCallback(() => {
if (isOpen) {
// 当前已打开 → 关闭
onClose();
} else {
// 当前已关闭 → 打开
onOpen();
isClickedRef.current = true; // 标记为点击打开
}
}, [isOpen, onOpen, onClose]);
/**
* Hover 进入处理
* - 打开菜单
* - 清除点击标记(允许 hover 离开时自动关闭)
*/
const handleMouseEnter = useCallback(() => {
onOpen();
isClickedRef.current = false; // 清除点击标记,允许 hover 控制
}, [onOpen]);
/**
* Hover 离开处理
* - 延迟关闭菜单
*/
const handleMouseLeave = useCallback(() => {
onDelayedClose();
}, [onDelayedClose]);
/**
* 点击处理
* - 切换菜单状态
*/
const handleClick = useCallback(() => {
onToggle();
}, [onToggle]);
// 组件卸载时清理定时器
const cleanup = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
return {
isOpen,
onOpen,
onClose,
onDelayedClose,
onToggle,
handleMouseEnter,
handleMouseLeave,
handleClick,
cleanup
};
}

View File

@@ -25,7 +25,12 @@ async function startApp() {
// Render the app with Router wrapper
root.render(
<React.StrictMode>
<Router>
<Router
future={{
// v7_startTransition: true, // 禁用导致路由切换延迟2秒影响用户体验
v7_relativeSplatPath: true,
}}
>
<App />
</Router>
</React.StrictMode>

View File

@@ -1,67 +0,0 @@
// src/layouts/Auth.js
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import ErrorBoundary from '../components/ErrorBoundary';
// 导入认证相关页面
import SignInIllustration from '../views/Authentication/SignIn/SignInIllustration';
import SignUpIllustration from '../views/Authentication/SignUp/SignUpIllustration';
// 认证路由组件 - 已登录用户不能访问登录页
const AuthRoute = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
// 加载中不做跳转
if (isLoading) {
return children;
}
// 已登录用户跳转到首页
if (isAuthenticated) {
// 检查是否有记录的重定向路径
const redirectPath = localStorage.getItem('redirectPath');
if (redirectPath && redirectPath !== '/auth/signin' && redirectPath !== '/auth/sign-up') {
localStorage.removeItem('redirectPath');
return <Navigate to={redirectPath} replace />;
}
return <Navigate to="/home" replace />;
}
return children;
};
export default function Auth() {
return (
<ErrorBoundary>
<Box minH="100vh">
<Routes>
{/* 登录页面 */}
<Route
path="/signin"
element={
<AuthRoute>
<SignInIllustration />
</AuthRoute>
}
/>
{/* 注册页面 */}
<Route
path="/sign-up"
element={
<AuthRoute>
<SignUpIllustration />
</AuthRoute>
}
/>
{/* 默认重定向到登录页 */}
<Route path="/" element={<Navigate to="/auth/signin" replace />} />
<Route path="*" element={<Navigate to="/auth/signin" replace />} />
</Routes>
</Box>
</ErrorBoundary>
);
}

View File

@@ -1,98 +0,0 @@
// src/layouts/Home.js
import React from "react";
import { Routes, Route } from "react-router-dom";
import { Box } from '@chakra-ui/react';
// 导航栏已由 MainLayout 提供,此处不再导入
// import HomeNavbar from "../components/Navbars/HomeNavbar";
// 导入页面组件
import HomePage from "views/Home/HomePage";
import ProfilePage from "views/Profile/ProfilePage";
import SettingsPage from "views/Settings/SettingsPage";
import CenterDashboard from "views/Dashboard/Center";
import Subscription from "views/Pages/Account/Subscription";
// 懒加载隐私政策、用户协议、微信回调和模拟交易页面
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// 导入保护路由组件
import ProtectedRoute from "../components/ProtectedRoute";
export default function Home() {
return (
<Box minH="100vh">
{/* 导航栏已由 MainLayout 提供,此处不再渲染 */}
{/* 主要内容区域 */}
<Box>
<Routes>
{/* 首页默认路由 */}
<Route path="/" element={<HomePage />} />
<Route
path="/center"
element={
<ProtectedRoute>
<CenterDashboard />
</ProtectedRoute>
}
/>
{/* 需要登录保护的页面 */}
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
{/* 订阅管理页面 */}
<Route
path="/pages/account/subscription"
element={
<ProtectedRoute>
<Subscription />
</ProtectedRoute>
}
/>
{/* 模拟盘交易页面 */}
<Route
path="/trading-simulation"
element={
<ProtectedRoute>
<TradingSimulation />
</ProtectedRoute>
}
/>
{/* 隐私政策页面 - 无需登录 */}
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
{/* 用户协议页面 - 无需登录 */}
<Route path="/user-agreement" element={<UserAgreement />} />
{/* 微信授权回调页面 - 无需登录 */}
<Route path="/wechat-callback" element={<WechatCallback />} />
{/* 其他可能的路由 */}
<Route path="*" element={<HomePage />} />
</Routes>
</Box>
</Box>
);
}

View File

@@ -1,13 +1,14 @@
// src/layouts/MainLayout.js
// 主布局组件 - 为所有带导航栏的页面提供统一布局
import React, { memo } from "react";
import { Outlet, useLocation } from "react-router-dom";
import React, { memo, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { Box } from '@chakra-ui/react';
import HomeNavbar from "../components/Navbars/HomeNavbar";
import AppFooter from "./AppFooter";
import BackToTopButton from "./components/BackToTopButton";
import PageTransitionWrapper from "./components/PageTransitionWrapper";
import { ANIMATION_CONFIG, BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
import ErrorBoundary from "../components/ErrorBoundary";
import PageLoader from "../components/Loading/PageLoader";
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
@@ -20,38 +21,27 @@ const MemoizedAppFooter = memo(AppFooter);
* 使用 <Outlet /> 渲染子路由,确保导航栏只渲染一次
* 页面切换时只有 Outlet 内的内容会更新,导航栏保持不变
*
* 架构优化2024-10-30
* - ✅ P0: 组件拆分 - BackToTopButton 独立复用37行 → 独立文件)
* - ✅ P0: 组件拆分 - PageTransitionWrapper 封装复杂逻辑18行 → 独立文件)
* - ✅ P0: 性能优化 - 使用 memo 避免导航栏和页脚重新渲染(性能提升 50%+
* - ✅ P1: 性能优化 - 使用 RAF 节流滚动事件(性能提升 80%
* - ✅ P1: 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
* - ✅ P2: 用户体验 - 页面过渡动画framer-motion
* - ✅ P2: 配置集中 - layoutConfig 统一管理配置常量
* - ✅ P3: 用户体验 - 返回顶部按钮(滚动 > 300px 显示)
*
* 代码优化成果:
* - 代码量115 行 → 42 行(减少 63%
* - 复杂度:内联组件 → 独立模块
* - 可维护性:配置分散 → 集中管理
* - 可复用性:耦合 → 解耦
* 架构优化:
* - ✅ 组件拆分 - BackToTopButton 独立复用
* - ✅ 性能优化 - 使用 memo 避免导航栏和页脚重新渲染
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
*/
export default function MainLayout() {
const location = useLocation();
return (
<Box minH="100vh" display="flex" flexDirection="column">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - 包含动画、错误边界、懒加载 */}
<PageTransitionWrapper
location={location}
animationConfig={ANIMATION_CONFIG.default}
loaderMessage="页面加载中..."
>
<Outlet />
</PageTransitionWrapper>
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" w="100%" position="relative" overflow="hidden">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</Box>
{/* 页脚 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedAppFooter />

View File

@@ -1,66 +0,0 @@
// src/layouts/components/PageTransitionWrapper.js
import React, { Suspense, memo } from 'react';
import { Box } from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import ErrorBoundary from '../../components/ErrorBoundary';
import PageLoader from '../../components/Loading/PageLoader';
// 创建 motion 包裹的 Box 组件
const MotionBox = motion(Box);
/**
* 页面过渡动画包裹组件
*
* 功能:
* - 页面切换时的过渡动画AnimatePresence
* - 错误边界隔离ErrorBoundary
* - 懒加载支持Suspense
*
* 优化:
* - ✅ 使用 memo 避免不必要的重新渲染
* - ✅ 支持自定义动画配置
* - ✅ 错误隔离,确保导航栏不受影响
*
* @param {React.ReactNode} children - 要渲染的子组件(通常是 <Outlet />
* @param {object} location - 路由位置对象(用于动画 key
* @param {object} animationConfig - 自定义动画配置
* @param {string} loaderMessage - 加载时显示的消息
*/
const PageTransitionWrapper = memo(({
children,
location,
animationConfig = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.2 }
},
loaderMessage = '页面加载中...'
}) => {
return (
<Box flex="1" position="relative" overflow="hidden">
<AnimatePresence mode="wait">
<MotionBox
key={location.pathname}
initial={animationConfig.initial}
animate={animationConfig.animate}
exit={animationConfig.exit}
transition={animationConfig.transition}
style={{ height: '100%' }}
>
{/* 错误边界:隔离页面错误,确保导航栏仍可用 */}
<ErrorBoundary>
{/* Suspense支持 React.lazy() 懒加载 */}
<Suspense fallback={<PageLoader message={loaderMessage} />}>
{children}
</Suspense>
</ErrorBoundary>
</MotionBox>
</AnimatePresence>
</Box>
);
});
PageTransitionWrapper.displayName = 'PageTransitionWrapper';
export default PageTransitionWrapper;

View File

@@ -609,7 +609,7 @@ function generateEventDescription(industry, importance, seed) {
return impacts[importance] + details[seed % details.length];
}
// 生成关键词
// 生成关键词(对象数组格式,包含完整信息)
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
const industryKeywords = {
@@ -620,12 +620,100 @@ function generateKeywords(industry, seed) {
'消费': ['白酒', '食品', '家电', '零售', '免税'],
};
const keywords = [
// 概念描述模板
const descriptionTemplates = {
'政策': '政策性利好消息对相关行业和板块产生积极影响,带动市场情绪和资金流向。',
'利好': '市场积极因素推动相关板块上涨,投资者情绪乐观,资金持续流入。',
'业绩': '公司业绩超预期增长,盈利能力提升,市场给予更高估值预期。',
'涨停': '强势涨停板显示市场热度,短期资金追捧,板块效应明显。',
'龙头': '行业龙头企业具备竞争优势,市场地位稳固,带动板块走势。',
'突破': '技术面或基本面出现重大突破,打开上涨空间,吸引资金关注。',
'合作': '战略合作为公司带来新的增长点,业务协同效应显著。',
'投资': '重大投资项目落地,长期发展空间广阔,市场预期良好。',
'芯片': '国产芯片替代加速,自主可控需求强烈,政策和资金支持力度大。',
'晶圆': '晶圆产能紧张,供需关系改善,相关企业盈利能力提升。',
'封测': '封测环节景气度上行,订单饱满,产能利用率提高。',
'AI芯片': '人工智能快速发展带动AI芯片需求爆发市场空间巨大。',
'国产替代': '国产替代进程加速,政策扶持力度大,进口依赖度降低。',
'电池': '新能源汽车渗透率提升,动力电池需求旺盛,技术迭代加快。',
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。',
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。',
'新能源车': '新能源汽车销量高增长,渗透率持续提升,产业链受益明显。',
'锂电': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。',
'大模型': '大语言模型技术突破,商业化进程加速,应用场景广阔。',
'AI应用': '人工智能应用落地加速,垂直领域渗透率提升,市场空间巨大。',
'算力': '算力需求持续增长,数据中心建设加速,相关产业链受益。',
'数据': '数据要素市场化改革推进,数据价值释放,相关企业盈利模式清晰。',
'机器学习': '机器学习技术成熟,应用场景丰富,商业价值逐步显现。',
'创新药': '创新药研发管线丰富,商业化进程顺利,市场给予高估值。',
'CRO': 'CRO行业高景气订单充足盈利能力稳定增长。',
'医疗器械': '医疗器械国产化率提升,技术创新加快,市场份额扩大。',
'生物制药': '生物制药技术突破,产品管线丰富,商业化前景广阔。',
'仿制药': '仿制药集采常态化,质量优势企业市场份额提升。',
'白酒': '白酒消费升级,高端产品量价齐升,龙头企业护城河深厚。',
'食品': '食品饮料需求稳定,品牌力强的企业市场份额持续提升。',
'家电': '家电消费需求回暖,智能化升级带动产品结构优化。',
'零售': '零售行业数字化转型,线上线下融合,运营效率提升。',
'免税': '免税政策优化,消费回流加速,行业景气度上行。'
};
const keywordNames = [
...commonKeywords.slice(seed % 3, seed % 3 + 3),
...(industryKeywords[industry] || []).slice(0, 2)
];
].slice(0, 5);
return keywords.slice(0, 5);
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成历史触发时间3-5个历史日期
const generateHappenedTimes = (baseSeed) => {
const times = [];
const count = 3 + (baseSeed % 3); // 3-5个时间点
for (let i = 0; i < count; i++) {
const daysAgo = 30 + (baseSeed * 7 + i * 11) % 330; // 30-360天前
const date = new Date();
date.setDate(date.getDate() - daysAgo);
times.push(date.toISOString().split('T')[0]);
}
return times.sort().reverse(); // 降序排列
};
// 生成核心相关股票4-6只
const generateRelatedStocks = (conceptName, baseSeed) => {
const stockCount = 4 + (baseSeed % 3); // 4-6只股票
const selectedStocks = [];
for (let i = 0; i < stockCount && i < stockPool.length; i++) {
const stockIndex = (baseSeed + i * 7) % stockPool.length;
const stock = stockPool[stockIndex];
selectedStocks.push({
stock_name: stock.stock_name,
stock_code: stock.stock_code,
reason: relationDescTemplates[(baseSeed + i) % relationDescTemplates.length],
change_pct: (Math.random() * 15 - 5).toFixed(2) // -5% ~ +10%
});
}
return selectedStocks;
};
// 将字符串数组转换为对象数组匹配真实API数据结构
return keywordNames.map((name, index) => {
const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数
const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅
return {
concept: name, // 使用 concept 字段而不是 name
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
score: parseFloat(score.toFixed(2)), // 0-1之间的分数而不是0-100
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
price_info: { // 将 avg_change_pct 嵌套在 price_info 对象中
avg_change_pct: parseFloat(avgChangePct)
},
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
};
});
}
/**
@@ -645,12 +733,12 @@ export function generateMockEvents(params = {}) {
stock_code = '',
} = params;
// 生成100个事件用于测试
const totalEvents = 100;
// 生成200个事件用于测试(足够测试分页功能)
const totalEvents = 200;
const allEvents = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
const baseDate = new Date('2025-01-15');
const baseDate = new Date(); // 使用当前日期作为基准
for (let i = 0; i < totalEvents; i++) {
const industry = industries[i % industries.length];
@@ -665,27 +753,87 @@ export function generateMockEvents(params = {}) {
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%
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
// 生成价格走势数据(前一天、当天、后一天)
const generatePriceTrend = (seed) => {
const basePrice = 10 + (seed % 90); // 基础价格 10-100
const trend = [];
// 前一天5个数据点
let price = basePrice;
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.5) * 0.5;
trend.push(parseFloat(price.toFixed(2)));
}
// 当天5个数据点
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.4) * 0.8; // 轻微上涨趋势
trend.push(parseFloat(price.toFixed(2)));
}
// 后一天5个数据点
for (let i = 0; i < 5; i++) {
price = price + (Math.random() - 0.45) * 1.0;
trend.push(parseFloat(price.toFixed(2)));
}
return trend;
};
// 为每个事件随机选择2-5个相关股票
const relatedStockCount = 2 + (i % 4); // 2-5个股票
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
const addedStockCodes = new Set(); // 用于去重
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
const stock = industryStocks[j % industryStocks.length];
if (!addedStockCodes.has(stock.stock_code)) {
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
relatedStocks.push({
stock_name: stock.stock_name,
stock_code: stock.stock_code,
relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length],
daily_change: dailyChange,
week_change: weekChange,
price_trend: generatePriceTrend(i * 100 + j)
});
addedStockCodes.add(stock.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);
let poolIndex = 0;
while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) {
const randomStock = stockPool[poolIndex % stockPool.length];
if (!addedStockCodes.has(randomStock.stock_code)) {
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
relatedStocks.push({
stock_name: randomStock.stock_name,
stock_code: randomStock.stock_code,
relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length],
daily_change: dailyChange,
week_change: weekChange,
price_trend: generatePriceTrend(i * 100 + poolIndex)
});
addedStockCodes.add(randomStock.stock_code);
}
poolIndex++;
}
// 计算交易日期模拟下一交易日这里简单地加1天
const tradingDate = new Date(createdAt);
tradingDate.setDate(tradingDate.getDate() + 1);
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
@@ -696,14 +844,18 @@ export function generateMockEvents(params = {}) {
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
trading_date: tradingDate.toISOString().split('T')[0], // YYYY-MM-DD 格式
hot_score: hotScore,
view_count: Math.floor(Math.random() * 10000),
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
related_week_chg: parseFloat(relatedWeekChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks, // 添加相关股票列表
historical_events: generateHistoricalEvents(industry, i), // 添加历史事件
transmission_chain: generateTransmissionChain(industry, i), // 添加传导链数据
});
}
@@ -816,3 +968,213 @@ export function generatePopularKeywords(limit = 20) {
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
}));
}
/**
* 生成历史事件对比数据
* @param {string} industry - 行业
* @param {number} index - 索引
* @returns {Array} - 历史事件列表
*/
function generateHistoricalEvents(industry, index) {
const historicalCount = 3 + (index % 3); // 3-5个历史事件
const historical = [];
const baseDate = new Date();
for (let i = 0; i < historicalCount; i++) {
// 生成过去1-6个月的随机时间
const monthsAgo = 1 + Math.floor(Math.random() * 6);
const eventDate = new Date(baseDate);
eventDate.setMonth(eventDate.getMonth() - monthsAgo);
const similarityScore = 0.6 + Math.random() * 0.35; // 60%-95%相似度
historical.push({
id: `hist_${industry}_${index}_${i}`,
title: generateEventTitle(industry, i + index * 10),
created_at: eventDate.toISOString(),
related_avg_chg: parseFloat((Math.random() * 15 - 3).toFixed(2)),
related_max_chg: parseFloat((Math.random() * 25).toFixed(2)),
similarity_score: parseFloat(similarityScore.toFixed(2)),
view_count: Math.floor(Math.random() * 3000) + 500,
});
}
// 按相似度排序
historical.sort((a, b) => b.similarity_score - a.similarity_score);
return historical;
}
/**
* 生成传导链数据
* @param {string} industry - 行业
* @param {number} index - 索引
* @returns {Object} - 传导链数据 { nodes, edges }
*/
function generateTransmissionChain(industry, index) {
const nodeTypes = ['event', 'industry', 'company', 'policy', 'technology', 'market'];
const impactTypes = ['positive', 'negative', 'neutral', 'mixed'];
const strengthLevels = ['strong', 'medium', 'weak'];
const nodes = [];
const edges = [];
// 主事件节点
nodes.push({
id: 1,
name: '主事件',
type: 'event',
extra: { is_main_event: true, description: `${industry}重要事件` }
});
// 生成5-8个相关节点
const nodeCount = 5 + (index % 4);
for (let i = 2; i <= nodeCount; i++) {
const nodeType = nodeTypes[i % nodeTypes.length];
const industryStock = stockPool.find(s => s.industry === industry);
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
nodeName = '相关政策';
} else if (nodeType === 'technology') {
nodeName = '技术创新';
} else if (nodeType === 'market') {
nodeName = '市场需求';
} else {
nodeName = `节点${i}`;
}
nodes.push({
id: i,
name: nodeName,
type: nodeType,
extra: { description: `${nodeName}相关信息` }
});
// 创建与主事件或其他节点的连接
const targetId = i === 2 ? 1 : Math.max(1, Math.floor(Math.random() * (i - 1)) + 1);
edges.push({
source: targetId,
target: i,
impact: impactTypes[i % impactTypes.length],
strength: strengthLevels[i % strengthLevels.length],
description: `传导路径${i}`
});
}
return { nodes, edges };
}
/**
* 生成动态新闻事件(实时要闻·动态追踪专用)
* @param {Object} timeRange - 时间范围 { startTime, endTime }
* @param {number} count - 生成事件数量默认30条
* @returns {Array} - 事件列表
*/
export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const events = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
// 如果没有提供时间范围默认生成最近24小时的事件
let startTime, endTime;
if (timeRange) {
startTime = new Date(timeRange.startTime);
endTime = new Date(timeRange.endTime);
} else {
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); // 24小时前
}
// 计算时间跨度(毫秒)
const timeSpan = endTime.getTime() - startTime.getTime();
for (let i = 0; i < count; i++) {
const industry = industries[i % industries.length];
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
// 在时间范围内随机生成事件时间
const randomOffset = Math.random() * timeSpan;
const createdAt = new Date(startTime.getTime() + randomOffset);
// 生成随机热度和收益率
const hotScore = Math.max(60, 100 - i * 1.2); // 动态新闻热度更高
const relatedAvgChg = (Math.random() * 15 - 3).toFixed(2); // -3% 到 12%
const relatedMaxChg = (Math.random() * 25).toFixed(2); // 0% 到 25%
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
// 为每个事件随机选择2-5个相关股票完整对象
const relatedStockCount = 2 + (i % 4);
const relatedStocks = [];
const industryStocks = stockPool.filter(s => s.industry === industry);
const relationDescriptions = [
'直接受益标的',
'产业链上游企业',
'产业链下游企业',
'行业龙头企业',
'潜在受益标的',
'概念相关个股'
];
// 优先选择同行业股票
if (industryStocks.length > 0) {
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
}
// 如果同行业股票不够,从整个 stockPool 中补充
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
const randomStock = stockPool[relatedStocks.length % stockPool.length];
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}
}
events.push({
id: `dynamic_${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() * 5000) + 1000, // 1000-6000 浏览量
follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数
post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
related_week_chg: parseFloat(relatedWeekChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks,
historical_events: generateHistoricalEvents(industry, i),
transmission_chain: generateTransmissionChain(industry, i),
creator: {
username: authorPool[i % authorPool.length],
avatar_url: null
}
});
}
// 按时间倒序排序(最新的在前)
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return events;
}

View File

@@ -6,8 +6,50 @@ import { http, HttpResponse } from 'msw';
// 模拟延迟
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 生成历史触发时间3-5个历史日期
const generateHappenedTimes = (seed) => {
const times = [];
const count = 3 + (seed % 3); // 3-5个时间点
for (let i = 0; i < count; i++) {
const daysAgo = 30 + (seed * 7 + i * 11) % 330; // 30-360天前
const date = new Date();
date.setDate(date.getDate() - daysAgo);
times.push(date.toISOString().split('T')[0]);
}
return times.sort().reverse(); // 降序排列
};
// 生成核心相关股票
const generateStocksForConcept = (seed, count = 4) => {
const stockPool = [
{ name: '贵州茅台', code: '600519' },
{ name: '宁德时代', code: '300750' },
{ name: '中国平安', code: '601318' },
{ name: '比亚迪', code: '002594' },
{ name: '隆基绿能', code: '601012' },
{ name: '阳光电源', code: '300274' },
{ name: '三一重工', code: '600031' },
{ name: '中芯国际', code: '688981' },
{ name: '京东方A', code: '000725' },
{ name: '立讯精密', code: '002475' }
];
const stocks = [];
for (let i = 0; i < count; i++) {
const stockIndex = (seed + i * 7) % stockPool.length;
const stock = stockPool[stockIndex];
stocks.push({
stock_name: stock.name,
stock_code: stock.code,
reason: `作为行业龙头企业,${stock.name}在该领域具有核心竞争优势,市场份额领先,技术实力雄厚。`,
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
});
}
return stocks;
};
// 生成热门概念数据
const generatePopularConcepts = (size = 20) => {
export const generatePopularConcepts = (size = 20) => {
const concepts = [
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
'储能', '氢能源', '风电', '特高压', '工业母机',
@@ -22,21 +64,38 @@ const generatePopularConcepts = (size = 20) => {
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
];
const conceptDescriptions = {
'人工智能': '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破AI应用场景不断拓展预计将催化算力、数据、应用三大产业链。',
'新能源汽车': '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益明显。',
'半导体': '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。',
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下,光伏行业前景广阔。',
'锂电池': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。',
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。',
'默认': '该概念市场关注度较高,具有一定的投资价值。相关企业技术实力雄厚,市场前景广阔。'
};
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
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 只股票
const score = parseFloat((Math.random() * 5 + 3).toFixed(2)); // 3-8 分数范围
results.push({
concept: concepts[i],
concept_id: `CONCEPT_${1000 + i}`,
stock_count: stockCount,
score: score, // 相关度分数
match_type: matchTypes[i % 3], // 匹配类型
description: conceptDescriptions[concepts[i]] || conceptDescriptions['默认'],
price_info: {
avg_change_pct: parseFloat(changePct),
avg_price: (Math.random() * 100 + 10).toFixed(2),
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
},
description: `${concepts[i]}相关概念股`,
happened_times: generateHappenedTimes(i), // 历史触发时间
stocks: generateStocksForConcept(i, 4), // 核心相关股票
hot_score: Math.floor(Math.random() * 100)
});
}
@@ -115,15 +174,12 @@ export const conceptHandlers = [
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
// 生成数据
// 生成数据(不过滤,模拟真实 API 的语义搜索返回热门概念)
let results = generatePopularConcepts(size);
console.log('[Mock Concept] 生成概念数量:', results.length);
// 如果有查询关键词,过滤结果
if (query) {
results = results.filter(item =>
item.concept.toLowerCase().includes(query.toLowerCase())
);
}
// Mock 环境下不做过滤,直接返回热门概念
// 真实环境会根据 query 进行语义搜索
// 根据排序字段排序
if (sort_by === 'change_pct') {

View File

@@ -2,8 +2,9 @@
// 事件相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
import { generatePopularConcepts } from './concept';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
@@ -111,6 +112,47 @@ export const eventHandlers = [
}
}),
// 获取动态新闻(实时要闻·动态追踪专用)
http.get('/api/events/dynamic-news', async ({ request }) => {
await delay(400);
const url = new URL(request.url);
const count = parseInt(url.searchParams.get('count') || '30');
const startTime = url.searchParams.get('start_time');
const endTime = url.searchParams.get('end_time');
console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime);
try {
let timeRange = null;
if (startTime && endTime) {
timeRange = {
startTime: new Date(startTime),
endTime: new Date(endTime)
};
}
const events = generateDynamicNewsEvents(timeRange, count);
return HttpResponse.json({
success: true,
data: events,
total: events.length,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取动态新闻失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取动态新闻失败',
data: []
},
{ status: 500 }
);
}
}),
// ==================== 事件详情相关 ====================
// 获取事件相关股票
@@ -142,6 +184,71 @@ export const eventHandlers = [
}
}),
// 获取事件相关概念
http.get('/api/events/:eventId/concepts', async ({ params }) => {
await delay(300);
const { eventId } = params;
console.log('[Mock] 获取事件相关概念, eventId:', eventId);
try {
// 返回热门概念列表(模拟真实场景下根据事件标题搜索的结果)
const concepts = generatePopularConcepts(5);
return HttpResponse.json({
success: true,
data: concepts,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件相关概念失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件相关概念失败',
data: []
},
{ status: 500 }
);
}
}),
// 切换事件关注状态
http.post('/api/events/:eventId/follow', async ({ params }) => {
await delay(200);
const { eventId } = params;
console.log('[Mock] 切换事件关注状态, eventId:', eventId);
try {
// 模拟切换逻辑:随机生成关注状态
// 实际应用中,这里应该从某个状态存储中读取和更新
const isFollowing = Math.random() > 0.5;
const followerCount = Math.floor(Math.random() * 1000) + 100;
return HttpResponse.json({
success: true,
data: {
is_following: isFollowing,
follower_count: followerCount
},
message: isFollowing ? '关注成功' : '取消关注成功'
});
} catch (error) {
console.error('[Mock] 切换事件关注状态失败:', error);
return HttpResponse.json(
{
success: false,
error: '切换关注状态失败',
data: null
},
{ status: 500 }
);
}
}),
// 获取事件传导链分析数据
http.get('/api/events/:eventId/transmission', async ({ params }) => {
await delay(500);

View File

@@ -1,53 +0,0 @@
// src/routes/components/RouteContainer.js
// 路由容器组件 - 提供统一的错误边界、加载状态和主题背景
import React, { Suspense } from 'react';
import { Box, useColorMode } from '@chakra-ui/react';
import ErrorBoundary from '@components/ErrorBoundary';
import PageLoader from '@components/Loading/PageLoader';
/**
* RouteContainer - 路由容器组件
*
* 为路由系统提供统一的外层包装,包含:
* 1. 主题感知的背景色(深色/浅色模式)
* 2. Suspense 懒加载边界(显示加载提示)
* 3. ErrorBoundary 错误边界(隔离路由错误)
*
* 这个组件确保:
* - 所有路由页面都有一致的背景色
* - 懒加载组件有统一的加载提示
* - 单个路由的错误不会导致整个应用崩溃
*
* @param {Object} props
* @param {React.ReactNode} props.children - 子组件(通常是 Routes
* @param {string} [props.loadingMessage='加载页面中...'] - 加载提示文本
*
* @example
* <RouteContainer>
* <Routes>
* <Route path="/" element={<Home />} />
* </Routes>
* </RouteContainer>
*/
export function RouteContainer({
children,
loadingMessage = "加载页面中..."
}) {
const { colorMode } = useColorMode();
return (
<Box
minH="100vh"
bg={colorMode === 'dark' ? 'gray.800' : 'white'}
>
{/* Suspense 统一处理懒加载组件的加载状态 */}
<Suspense fallback={<PageLoader message={loadingMessage} />}>
{/* ErrorBoundary 隔离路由错误,防止整个应用崩溃 */}
<ErrorBoundary>
{children}
</ErrorBoundary>
</Suspense>
</Box>
);
}

View File

@@ -1,4 +0,0 @@
// src/routes/components/index.js
// 统一导出所有路由组件
export { RouteContainer } from './RouteContainer';

View File

@@ -1,5 +1,5 @@
// src/routes/constants/index.js
// 统一导出所有路由常量
export { LAYOUT_COMPONENTS } from './layoutComponents';
export { PROTECTION_WRAPPER_MAP } from './protectionWrappers';
export { PROTECTION_MODES } from './protectionModes';

View File

@@ -1,26 +0,0 @@
// src/routes/constants/layoutComponents.js
// 布局组件映射表
import Auth from '@layouts/Auth';
import HomeLayout from '@layouts/Home';
/**
* 特殊布局组件映射表
*
* 用于将字符串标识符映射到实际的组件。
* 这些是非懒加载的布局组件,在 routeConfig.js 中通过字符串引用。
*
* @example
* // 在 routeConfig.js 中:
* {
* path: 'auth/*',
* component: 'Auth', // 字符串标识符
* ...
* }
*
* // 通过 LAYOUT_COMPONENTS['Auth'] 获取实际组件
*/
export const LAYOUT_COMPONENTS = {
Auth,
HomeLayout,
};

View File

@@ -0,0 +1,14 @@
// src/routes/constants/protectionModes.js
// 路由保护模式常量
/**
* 路由保护模式
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
* - 'public': 公开访问,无需登录
*/
export const PROTECTION_MODES = {
MODAL: 'modal',
REDIRECT: 'redirect',
PUBLIC: 'public',
};

View File

@@ -3,7 +3,7 @@
import ProtectedRoute from '@components/ProtectedRoute';
import ProtectedRouteRedirect from '@components/ProtectedRouteRedirect';
import { PROTECTION_MODES } from '../routeConfig';
import { PROTECTION_MODES } from './protectionModes';
/**
* 保护模式包装器映射表

115
src/routes/homeRoutes.js Normal file
View File

@@ -0,0 +1,115 @@
// src/routes/homeRoutes.js
// Home 模块子路由配置
import { lazyComponents } from './lazy-components';
import { PROTECTION_MODES } from './constants/protectionModes';
/**
* Home 模块的子路由配置
* 这些路由将作为 /home/* 的嵌套路由
*
* 注意:
* - 使用相对路径(不带前导斜杠)
* - 空字符串 '' 表示索引路由,匹配 /home
* - 这些路由将通过 Outlet 渲染到父路由中
*/
export const homeRoutes = [
// 首页 - /home
{
path: '',
component: lazyComponents.HomePage,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '首页',
description: '价值前沿首页'
}
},
// 个人中心 - /home/center
{
path: 'center',
component: lazyComponents.CenterDashboard,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '个人中心',
description: '用户个人中心'
}
},
// 个人资料 - /home/profile
{
path: 'profile',
component: lazyComponents.ProfilePage,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '个人资料',
description: '用户个人资料'
}
},
// 账户设置 - /home/settings
{
path: 'settings',
component: lazyComponents.SettingsPage,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '账户设置',
description: '用户账户设置'
}
},
// 订阅管理 - /home/pages/account/subscription
{
path: 'pages/account/subscription',
component: lazyComponents.Subscription,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '订阅管理',
description: '管理订阅服务'
}
},
// 隐私政策 - /home/privacy-policy
{
path: 'privacy-policy',
component: lazyComponents.PrivacyPolicy,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '隐私政策',
description: '隐私保护政策'
}
},
// 用户协议 - /home/user-agreement
{
path: 'user-agreement',
component: lazyComponents.UserAgreement,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '用户协议',
description: '用户使用协议'
}
},
// 微信授权回调 - /home/wechat-callback
{
path: 'wechat-callback',
component: lazyComponents.WechatCallback,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '微信授权',
description: '微信授权回调页面'
}
},
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',
component: lazyComponents.HomePage,
protection: PROTECTION_MODES.PUBLIC,
meta: {
title: '首页',
description: '价值前沿首页'
}
},
];

View File

@@ -10,9 +10,8 @@ import { getMainLayoutRoutes, getStandaloneRoutes } from './routeConfig';
// 布局组件
import MainLayout from '@layouts/MainLayout';
// 路由工具和组件
// 路由工具
import { renderRoute } from './utils';
import { RouteContainer } from './components';
/**
* AppRoutes - 应用路由组件
@@ -31,7 +30,11 @@ import { RouteContainer } from './components';
* 目录结构:
* - constants/ - 常量配置(布局映射、保护包装器)
* - utils/ - 工具函数renderRoute, wrapWithProtection
* - components/ - 路由组件RouteContainer
* - components/ - 路由相关组件
*
* 注意:
* - Suspense/ErrorBoundary 由 PageTransitionWrapper 统一处理
* - 全屏容器由 MainLayout 提供minH="100vh"
*/
export function AppRoutes() {
// 🎯 性能优化:使用 useMemo 缓存路由计算结果
@@ -39,23 +42,21 @@ export function AppRoutes() {
const standaloneRoutes = useMemo(() => getStandaloneRoutes(), []);
return (
<RouteContainer>
<Routes>
{/* 主布局路由 - 带导航栏和页脚 */}
<Route element={<MainLayout />}>
{mainLayoutRoutes.map(renderRoute)}
</Route>
<Routes>
{/* 主布局路由 - 带导航栏和页脚 */}
<Route element={<MainLayout />}>
{mainLayoutRoutes.map(renderRoute)}
</Route>
{/* 独立路由 - 无布局(如登录页)*/}
{standaloneRoutes.map(renderRoute)}
{/* 独立路由 - 无布局(如登录页)*/}
{standaloneRoutes.map(renderRoute)}
{/* 默认路由 - 重定向到首页 */}
<Route path="/" element={<Navigate to="/home" replace />} />
{/* 默认路由 - 重定向到首页 */}
<Route path="/" element={<Navigate to="/home" replace />} />
{/* 404 页面 - 捕获所有未匹配的路由 */}
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
</RouteContainer>
{/* 404 页面 - 捕获所有未匹配的路由 */}
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
);
}

View File

@@ -8,6 +8,16 @@ import React from 'react';
* 使用 React.lazy() 实现路由懒加载,大幅减少初始 JS 包大小
*/
export const lazyComponents = {
// Home 模块
HomePage: React.lazy(() => import('../views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
// 社区/内容模块
Community: React.lazy(() => import('../views/Community')),
ConceptCenter: React.lazy(() => import('../views/Concept')),
@@ -31,6 +41,14 @@ export const lazyComponents = {
* 按需导出单个组件(可选)
*/
export const {
HomePage,
CenterDashboard,
ProfilePage,
SettingsPage,
Subscription,
PrivacyPolicy,
UserAgreement,
WechatCallback,
Community,
ConceptCenter,
StockOverview,

View File

@@ -2,35 +2,30 @@
// 声明式路由配置
import { lazyComponents } from './lazy-components';
import { homeRoutes } from './homeRoutes';
import { PROTECTION_MODES } from './constants/protectionModes';
/**
* 路由保护模式
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
* - 'public': 公开访问,无需登录
*/
export const PROTECTION_MODES = {
MODAL: 'modal',
REDIRECT: 'redirect',
PUBLIC: 'public',
};
// 重新导出 PROTECTION_MODES 以保持向后兼容
export { PROTECTION_MODES };
/**
* 路由配置
* 每个路由对象包含:
* - path: 路由路径
* - component: 组件(从 lazyComponents 引用)
* - component: 组件(从 lazyComponents 引用,或设为 null 使用 Outlet)
* - protection: 保护模式 (modal/redirect/public)
* - layout: 布局类型 (main/auth/none)
* - children: 子路由配置数组(可选,用于嵌套路由)
* - meta: 路由元数据(可选,用于面包屑、标题等)
*/
export const routeConfig = [
// ==================== 首页 ====================
{
path: 'home/*',
component: 'HomeLayout', // 非懒加载,直接在 App.js 导入
path: 'home',
component: null, // 使用 Outlet 渲染子路由
protection: PROTECTION_MODES.PUBLIC,
layout: 'main',
children: homeRoutes, // 子路由配置
meta: {
title: '首页',
description: '价值前沿首页'
@@ -107,7 +102,7 @@ export const routeConfig = [
{
path: 'forecast-report',
component: lazyComponents.ForecastReport,
protection: PROTECTION_MODES.REDIRECT,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '财报预测',
@@ -115,7 +110,7 @@ export const routeConfig = [
}
},
{
path: 'Financial',
path: 'financial',
component: lazyComponents.FinancialPanorama,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
@@ -154,18 +149,6 @@ export const routeConfig = [
description: '实时市场数据'
}
},
// ==================== 认证模块 ====================
{
path: 'auth/*',
component: 'Auth', // 非懒加载,直接在 App.js 导入
protection: PROTECTION_MODES.PUBLIC,
layout: 'none',
meta: {
title: '登录/注册',
description: '用户认证'
}
},
];
/**

View File

@@ -2,8 +2,7 @@
// 路由渲染工具函数
import React from 'react';
import { Route } from 'react-router-dom';
import { LAYOUT_COMPONENTS } from '../constants';
import { Route, Outlet } from 'react-router-dom';
import { wrapWithProtection } from './wrapWithProtection';
/**
@@ -11,14 +10,16 @@ import { wrapWithProtection } from './wrapWithProtection';
*
* 根据路由配置项生成 React Router 的 Route 组件。
* 处理以下逻辑:
* 1. 解析组件(特殊布局组件 vs 懒加载组件)
* 1. 解析组件(懒加载组件 or Outlet
* 2. 应用路由保护(根据 protection 字段)
* 3. 生成唯一 key
* 3. 处理嵌套路由children 数组)
* 4. 生成唯一 key
*
* @param {Object} routeItem - 路由配置项(来自 routeConfig.js
* @param {string} routeItem.path - 路由路径
* @param {React.ComponentType|string} routeItem.component - 组件或组件标识符
* @param {React.ComponentType|null} routeItem.component - 懒加载组件或 nullnull 表示使用 Outlet
* @param {string} routeItem.protection - 保护模式 (modal/redirect/public)
* @param {Array} [routeItem.children] - 子路由配置数组(可选)
* @param {number} index - 路由索引,用于生成唯一 key
*
* @returns {React.ReactElement} Route 组件
@@ -27,19 +28,41 @@ import { wrapWithProtection } from './wrapWithProtection';
* // 使用示例
* const routes = [
* { path: 'community', component: CommunityComponent, protection: 'modal' },
* { path: 'auth/*', component: 'Auth', protection: 'public' },
* { path: 'home', component: null, protection: 'public', children: [...] },
* ];
*
* routes.map((route, index) => renderRoute(route, index));
*/
export function renderRoute(routeItem, index) {
const { path, component, protection } = routeItem;
const { path, component, protection, children } = routeItem;
// 解析组件:
// - 如果是字符串(如 'Auth', 'HomeLayout'),从 LAYOUT_COMPONENTS 映射表查找
// - 如果是 null使用 <Outlet /> 用于嵌套路由
// - 否则直接使用(懒加载组件)
const Component = LAYOUT_COMPONENTS[component] || component;
let Component;
let isOutletRoute = false;
if (component === null) {
Component = Outlet; // 用于嵌套路由
isOutletRoute = true;
} else {
Component = component; // 直接使用懒加载组件
}
// 如果有子路由,递归渲染
if (children && children.length > 0) {
return (
<Route
key={`${path}-${index}`}
path={path}
element={isOutletRoute ? <Outlet /> : wrapWithProtection(Component, protection)}
>
{children.map((childRoute, childIndex) => renderRoute(childRoute, childIndex))}
</Route>
);
}
// 没有子路由,渲染单个路由
return (
<Route
key={`${path}-${index}`}

View File

@@ -156,6 +156,117 @@ export const fetchHotEvents = createAsyncThunk(
}
);
/**
* 获取动态新闻(客户端缓存 + 智能请求)
* 用于 DynamicNewsCard 组件
* @param {Object} params - 请求参数
* @param {number} params.page - 页码
* @param {number} params.per_page - 每页数量
* @param {boolean} params.clearCache - 是否清空缓存(默认 false
* @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false
*/
export const fetchDynamicNews = createAsyncThunk(
'communityData/fetchDynamicNews',
async ({
page = 1,
per_page = 5,
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
clearCache = false,
prependMode = false
} = {}, { rejectWithValue }) => {
try {
logger.debug('CommunityData', '开始获取动态新闻', {
page,
per_page,
clearCache,
prependMode
});
const response = await eventService.getEvents({
page,
per_page,
sort: 'new'
});
if (response.success && response.data?.events) {
logger.info('CommunityData', '动态新闻加载成功', {
count: response.data.events.length,
page: response.data.pagination?.page || page,
total: response.data.pagination?.total || 0
});
return {
events: response.data.events,
total: response.data.pagination?.total || 0,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
clearCache,
prependMode
};
}
logger.warn('CommunityData', '动态新闻返回数据为空', response);
return {
events: [],
total: 0,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
clearCache,
prependMode
};
} catch (error) {
logger.error('CommunityData', '获取动态新闻失败', error);
return rejectWithValue(error.message || '获取动态新闻失败');
}
}
);
/**
* 切换事件关注状态
* 复用 EventList.js 中的关注逻辑
* @param {number} eventId - 事件ID
*/
export const toggleEventFollow = createAsyncThunk(
'communityData/toggleEventFollow',
async (eventId, { rejectWithValue }) => {
try {
logger.debug('CommunityData', '切换事件关注状态', { eventId });
// 调用 API自动切换关注状态后端根据当前状态决定关注/取消关注)
const response = await fetch(`/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '操作失败');
}
const isFollowing = data.data?.is_following;
const followerCount = data.data?.follower_count ?? 0;
logger.info('CommunityData', '关注状态切换成功', {
eventId,
isFollowing,
followerCount
});
return {
eventId,
isFollowing,
followerCount
};
} catch (error) {
logger.error('CommunityData', '切换关注状态失败', error);
return rejectWithValue(error.message || '切换关注状态失败');
}
}
);
// ==================== Slice 定义 ====================
const communityDataSlice = createSlice({
@@ -164,29 +275,36 @@ const communityDataSlice = createSlice({
// 数据
popularKeywords: [],
hotEvents: [],
dynamicNews: [], // 动态新闻完整缓存列表
dynamicNewsTotal: 0, // 服务端总数量
eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } }
// 加载状态
loading: {
popularKeywords: false,
hotEvents: false
hotEvents: false,
dynamicNews: false
},
// 错误信息
error: {
popularKeywords: null,
hotEvents: null
hotEvents: null,
dynamicNews: null
},
// 最后更新时间
lastUpdated: {
popularKeywords: null,
hotEvents: null
hotEvents: null,
dynamicNews: null
}
},
reducers: {
/**
* 清除所有缓存Redux + localStorage
* 注意dynamicNews 不使用 localStorage 缓存
*/
clearCache: (state) => {
// 清除 localStorage
@@ -195,15 +313,17 @@ const communityDataSlice = createSlice({
// 清除 Redux 状态
state.popularKeywords = [];
state.hotEvents = [];
state.dynamicNews = []; // 动态新闻也清除
state.lastUpdated.popularKeywords = null;
state.lastUpdated.hotEvents = null;
state.lastUpdated.dynamicNews = null;
logger.info('CommunityData', '所有缓存已清除');
},
/**
* 清除指定类型的缓存
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'dynamicNews')
*/
clearSpecificCache: (state, action) => {
const type = action.payload;
@@ -218,6 +338,11 @@ const communityDataSlice = createSlice({
state.hotEvents = [];
state.lastUpdated.hotEvents = null;
logger.info('CommunityData', '热点事件缓存已清除');
} else if (type === 'dynamicNews') {
// dynamicNews 不使用 localStorage只清除 Redux state
state.dynamicNews = [];
state.lastUpdated.dynamicNews = null;
logger.info('CommunityData', '动态新闻数据已清除');
}
},
@@ -228,6 +353,16 @@ const communityDataSlice = createSlice({
preloadData: (state) => {
logger.info('CommunityData', '准备预加载数据');
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
},
/**
* 设置单个事件的关注状态(同步)
* @param {Object} action.payload - { eventId, isFollowing, followerCount }
*/
setEventFollowStatus: (state, action) => {
const { eventId, isFollowing, followerCount } = action.payload;
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
}
},
@@ -235,16 +370,125 @@ const communityDataSlice = createSlice({
// 使用工厂函数创建 reducers消除重复代码
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
createDataReducers(builder, fetchHotEvents, 'hotEvents');
// dynamicNews 需要特殊处理(缓存 + 追加模式)
builder
.addCase(fetchDynamicNews.pending, (state) => {
state.loading.dynamicNews = true;
state.error.dynamicNews = null;
})
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
if (clearCache) {
// 清空缓存模式:直接替换
state.dynamicNews = events;
logger.debug('CommunityData', '清空缓存并加载新数据', {
count: events.length
});
} else if (prependMode) {
// 追加到头部模式(用于定时刷新):去重后插入头部
const existingIds = new Set(state.dynamicNews.map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...newEvents, ...state.dynamicNews];
logger.debug('CommunityData', '追加新数据到头部', {
newCount: newEvents.length,
totalCount: state.dynamicNews.length
});
} else {
// 智能插入模式:根据页码计算正确的插入位置
// 使用 pageSize每页显示量而不是 per_page请求数量
const startIndex = (page - 1) * (pageSize || per_page);
// 判断插入模式
const isAppend = startIndex === state.dynamicNews.length;
const isReplace = startIndex < state.dynamicNews.length;
const isJump = startIndex > state.dynamicNews.length;
// 只在 append 模式下去重(避免定时刷新重复)
// 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失)
if (isAppend) {
// Append 模式:连续加载,需要去重
const existingIds = new Set(
state.dynamicNews
.filter(e => e !== null)
.map(e => e.id)
);
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...state.dynamicNews, ...newEvents];
logger.debug('CommunityData', '连续追加数据(去重)', {
page,
startIndex,
endIndex: startIndex + newEvents.length,
originalEventsCount: events.length,
newEventsCount: newEvents.length,
filteredCount: events.length - newEvents.length,
totalCount: state.dynamicNews.length
});
} else if (isReplace) {
// 替换模式:直接覆盖,不去重
const endIndex = startIndex + events.length;
const before = state.dynamicNews.slice(0, startIndex);
const after = state.dynamicNews.slice(endIndex);
state.dynamicNews = [...before, ...events, ...after];
logger.debug('CommunityData', '替换重叠数据(不去重)', {
page,
startIndex,
endIndex,
eventsCount: events.length,
beforeLength: before.length,
afterLength: after.length,
totalCount: state.dynamicNews.length
});
} else {
// 跳页模式:填充间隔,不去重
const gap = startIndex - state.dynamicNews.length;
const fillers = Array(gap).fill(null);
state.dynamicNews = [...state.dynamicNews, ...fillers, ...events];
logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', {
page,
startIndex,
endIndex: startIndex + events.length,
gap,
eventsCount: events.length,
totalCount: state.dynamicNews.length
});
}
}
state.dynamicNewsTotal = total;
state.loading.dynamicNews = false;
state.lastUpdated.dynamicNews = new Date().toISOString();
})
.addCase(fetchDynamicNews.rejected, (state, action) => {
state.loading.dynamicNews = false;
state.error.dynamicNews = action.payload;
logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload));
})
// toggleEventFollow
.addCase(toggleEventFollow.fulfilled, (state, action) => {
const { eventId, isFollowing, followerCount } = action.payload;
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount });
})
.addCase(toggleEventFollow.rejected, (state, action) => {
logger.error('CommunityData', 'toggleEventFollow rejected', action.payload);
});
}
});
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions;
// 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
export const selectHotEvents = (state) => state.communityData.hotEvents;
export const selectDynamicNews = (state) => state.communityData.dynamicNews;
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
export const selectLoading = (state) => state.communityData.loading;
export const selectError = (state) => state.communityData.error;
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
@@ -264,6 +508,15 @@ export const selectHotEventsWithLoading = (state) => ({
lastUpdated: state.communityData.lastUpdated.hotEvents
});
export const selectDynamicNewsWithLoading = (state) => ({
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
loading: state.communityData.loading.dynamicNews,
error: state.communityData.error.dynamicNews,
total: state.communityData.dynamicNewsTotal, // 服务端总数量
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null
lastUpdated: state.communityData.lastUpdated.dynamicNews
});
// 工具函数:检查数据是否需要刷新(超过指定时间)
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
if (!lastUpdated) return true;

View File

@@ -2,35 +2,19 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
import { getApiBase } from '../../utils/apiConfig';
// ==================== 常量定义 ====================
// 缓存键名
const CACHE_KEYS = {
EVENT_STOCKS: 'event_stocks_',
EVENT_DETAIL: 'event_detail_',
HISTORICAL_EVENTS: 'historical_events_',
CHAIN_ANALYSIS: 'chain_analysis_',
EXPECTATION_SCORE: 'expectation_score_',
WATCHLIST: 'user_watchlist'
};
// 请求去重:缓存正在进行的请求
const pendingRequests = new Map();
// ==================== Async Thunks ====================
/**
* 获取事件相关股票(三级缓存)
* 获取事件相关股票(Redux 缓存)
*/
export const fetchEventStocks = createAsyncThunk(
'stock/fetchEventStocks',
async ({ eventId, forceRefresh = false }, { getState }) => {
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh });
// 1. Redux 状态缓存
// Redux 状态缓存
if (!forceRefresh) {
const cached = getState().stock.eventStocksCache[eventId];
if (cached && cached.length > 0) {
@@ -39,27 +23,13 @@ export const fetchEventStocks = createAsyncThunk(
}
}
// 2. LocalStorage 缓存
if (!forceRefresh) {
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_STOCKS + eventId);
if (localCached) {
logger.debug('stockSlice', 'LocalStorage 缓存命中', { eventId });
return { eventId, stocks: localCached };
}
}
// 3. API 请求
// API 请求
const res = await eventService.getRelatedStocks(eventId);
if (res.success && res.data) {
logger.debug('stockSlice', 'API 请求成功', {
eventId,
stockCount: res.data.length
});
localCacheManager.set(
CACHE_KEYS.EVENT_STOCKS + eventId,
res.data,
CACHE_EXPIRY_STRATEGY.LONG // 1小时
);
return { eventId, stocks: res.data };
}
@@ -84,7 +54,7 @@ export const fetchStockQuotes = createAsyncThunk(
);
/**
* 获取事件详情
* 获取事件详情Redux 缓存)
*/
export const fetchEventDetail = createAsyncThunk(
'stock/fetchEventDetail',
@@ -100,23 +70,9 @@ export const fetchEventDetail = createAsyncThunk(
}
}
// LocalStorage 缓存
if (!forceRefresh) {
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_DETAIL + eventId);
if (localCached) {
logger.debug('stockSlice', 'LocalStorage 缓存命中 - eventDetail', { eventId });
return { eventId, detail: localCached };
}
}
// API 请求
const res = await eventService.getEventDetail(eventId);
if (res.success && res.data) {
localCacheManager.set(
CACHE_KEYS.EVENT_DETAIL + eventId,
res.data,
CACHE_EXPIRY_STRATEGY.LONG
);
return { eventId, detail: res.data };
}
@@ -125,7 +81,7 @@ export const fetchEventDetail = createAsyncThunk(
);
/**
* 获取历史事件对比
* 获取历史事件对比Redux 缓存)
*/
export const fetchHistoricalEvents = createAsyncThunk(
'stock/fetchHistoricalEvents',
@@ -140,22 +96,9 @@ export const fetchHistoricalEvents = createAsyncThunk(
}
}
// LocalStorage 缓存
if (!forceRefresh) {
const localCached = localCacheManager.get(CACHE_KEYS.HISTORICAL_EVENTS + eventId);
if (localCached) {
return { eventId, events: localCached };
}
}
// API 请求
const res = await eventService.getHistoricalEvents(eventId);
if (res.success && res.data) {
localCacheManager.set(
CACHE_KEYS.HISTORICAL_EVENTS + eventId,
res.data,
CACHE_EXPIRY_STRATEGY.LONG
);
return { eventId, events: res.data };
}
@@ -164,7 +107,7 @@ export const fetchHistoricalEvents = createAsyncThunk(
);
/**
* 获取传导链分析
* 获取传导链分析Redux 缓存)
*/
export const fetchChainAnalysis = createAsyncThunk(
'stock/fetchChainAnalysis',
@@ -179,22 +122,9 @@ export const fetchChainAnalysis = createAsyncThunk(
}
}
// LocalStorage 缓存
if (!forceRefresh) {
const localCached = localCacheManager.get(CACHE_KEYS.CHAIN_ANALYSIS + eventId);
if (localCached) {
return { eventId, analysis: localCached };
}
}
// API 请求
const res = await eventService.getTransmissionChainAnalysis(eventId);
if (res.success && res.data) {
localCacheManager.set(
CACHE_KEYS.CHAIN_ANALYSIS + eventId,
res.data,
CACHE_EXPIRY_STRATEGY.LONG
);
return { eventId, analysis: res.data };
}

View File

@@ -9,7 +9,7 @@
--color-n-6: #252134;
--color-n-7: #15131D;
--color-n-8: #0E0C15;
/* Brainwave 主题色 */
--color-1: #AC6AFF;
--color-2: #FFC876;
@@ -17,7 +17,7 @@
--color-4: #7ADB78;
--color-5: #858DFF;
--color-6: #FF98E2;
/* 描边色 */
--stroke-1: #26242C;
}
@@ -47,37 +47,3 @@
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
}
.from-n-8 { --tw-gradient-from: var(--color-n-8); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.via-n-7 { --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--color-n-7), var(--tw-gradient-to); }
.to-n-6 { --tw-gradient-to: var(--color-n-6); }
/* 文字渐变 */
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)) !important; }
.from-color-1 { --tw-gradient-from: var(--color-1); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.to-color-2 { --tw-gradient-to: var(--color-2); }
.from-color-2 { --tw-gradient-from: var(--color-2); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.to-color-1 { --tw-gradient-to: var(--color-1); }
.bg-clip-text {
-webkit-background-clip: text !important;
background-clip: text !important;
}
.text-transparent {
color: transparent !important;
}
/* 其他样式增强 */
.backdrop-blur-sm { backdrop-filter: blur(8px) !important; }
.backdrop-blur { backdrop-filter: blur(16px) !important; }
/* 确保body有深色背景 */
body {
background-color: var(--color-n-8) !important;
}
/* z-index 修复 */
.z-50 { z-index: 50 !important; }
.z-10 { z-index: 10 !important; }
.z-2 { z-index: 2 !important; }
.z-1 { z-index: 1 !important; }

View File

@@ -32,4 +32,4 @@ body {
@apply md:grid !important;
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
}
}
}

View File

@@ -0,0 +1,175 @@
// src/utils/tradingTimeUtils.js
// 交易时间相关工具函数
import moment from 'moment';
/**
* 获取当前时间应该显示的实时要闻时间范围
* 规则:
* - 15:00 之前:显示昨日 15:00 - 今日 15:00
* - 15:30 之后:显示今日 15:00 - 当前时间
*
* @returns {{ startTime: Date, endTime: Date, description: string }}
*/
export const getCurrentTradingTimeRange = () => {
const now = moment();
const currentHour = now.hour();
const currentMinute = now.minute();
// 计算当前是第几分钟(方便比较)
const currentTimeInMinutes = currentHour * 60 + currentMinute;
const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
let startTime, endTime, description;
if (currentTimeInMinutes < cutoffTime1500) {
// 15:00 之前:显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
} else if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示今日 15:00 - 当前时间
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = now.toDate();
description = '今日15:00 - 当前时间';
} else {
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
}
return {
startTime,
endTime,
description,
rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day'
};
};
/**
* 获取市场复盘的时间范围
* 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00
*
* @returns {{ startTime: Date, endTime: Date, description: string }}
*/
export const getMarketReviewTimeRange = () => {
const now = moment();
const currentHour = now.hour();
const currentMinute = now.minute();
// 计算当前是第几分钟
const currentTimeInMinutes = currentHour * 60 + currentMinute;
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
let startTime, endTime, description;
if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00';
} else {
// 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
description = '前日15:00 - 昨日15:00';
}
return {
startTime,
endTime,
description,
rangeType: 'market_review'
};
};
/**
* 根据时间范围过滤事件列表
*
* @param {Array} events - 事件列表
* @param {Date} startTime - 开始时间
* @param {Date} endTime - 结束时间
* @returns {Array} 过滤后的事件列表
*/
export const filterEventsByTimeRange = (events, startTime, endTime) => {
if (!events || !Array.isArray(events)) {
return [];
}
if (!startTime || !endTime) {
return events;
}
const startMoment = moment(startTime);
const endMoment = moment(endTime);
return events.filter(event => {
if (!event.created_at) {
return false;
}
const eventTime = moment(event.created_at);
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
});
};
/**
* 判断当前是否应该显示市场复盘模块
* 根据需求:市场复盘模块一直显示
*
* @returns {boolean}
*/
export const shouldShowMarketReview = () => {
// 市场复盘模块始终显示
return true;
};
/**
* 获取时间范围的描述文本
*
* @param {Date} startTime - 开始时间
* @param {Date} endTime - 结束时间
* @returns {string}
*/
export const getTimeRangeDescription = (startTime, endTime) => {
if (!startTime || !endTime) {
return '';
}
const startStr = moment(startTime).format('MM-DD HH:mm');
const endStr = moment(endTime).format('MM-DD HH:mm');
return `${startStr} - ${endStr}`;
};
/**
* 判断是否为交易日(简化版本,只判断周末)
* 注意这里没有考虑节假日如需精确判断需要接入交易日历API
*
* @param {Date} date - 日期
* @returns {boolean}
*/
export const isTradingDay = (date) => {
const day = moment(date).day();
// 0 = 周日, 6 = 周六
return day !== 0 && day !== 6;
};
/**
* 获取上一个交易日(简化版本)
*
* @param {Date} date - 日期
* @returns {Date}
*/
export const getPreviousTradingDay = (date) => {
let prevDay = moment(date).subtract(1, 'day');
// 如果是周末,继续往前找
while (!isTradingDay(prevDay.toDate())) {
prevDay = prevDay.subtract(1, 'day');
}
return prevDay.toDate();
};

View File

@@ -1,160 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import React, { useState } from "react";
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
Input,
Stack,
useColorModeValue,
Text,
Link,
InputGroup,
InputRightElement,
IconButton,
useToast,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignInBasic() {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
email: "",
password: "",
});
const navigate = useNavigate();
const toast = useToast();
const { login, isLoading } = useAuth();
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.email || !formData.password) {
toast({
title: "请填写完整信息",
description: "邮箱和密码都是必填项",
status: "warning",
duration: 3000,
isClosable: true,
});
return;
}
const result = await login(formData.email, formData.password, 'email');
if (result.success) {
// 登录成功,跳转到首页
navigate("/home");
}
};
return (
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
<Stack align="center">
<Heading style={{minWidth: '140px'}} fontSize="4xl" color="blue.600">
价小前投研
</Heading>
<Text fontSize="lg" color="gray.600">
登录您的账户
</Text>
</Stack>
<Box
rounded="lg"
bg={useColorModeValue("white", "gray.700")}
boxShadow="lg"
p={8}
>
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl id="email" isRequired>
<FormLabel>邮箱地址</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="请输入您的邮箱"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="请输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<Stack spacing={10}>
<Button
type="submit"
bg="blue.600"
color="white"
_hover={{
bg: "blue.700",
}}
isLoading={isLoading}
loadingText="登录中..."
>
登录
</Button>
</Stack>
<Stack pt={6}>
<Text align="center">
还没有账户?{" "}
<Link color="blue.600" onClick={() => navigate("/auth/signup")}>
立即注册
</Link>
</Text>
</Stack>
</Stack>
</form>
</Box>
</Stack>
</Flex>
);
}

View File

@@ -1,207 +0,0 @@
import React, { useState } from "react";
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
Text,
Link,
useColorMode,
InputGroup,
InputRightElement,
IconButton,
Spinner,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignInCentered() {
const { colorMode } = useColorMode();
const navigate = useNavigate();
const { login, isLoading } = useAuth();
// 表单状态
const [formData, setFormData] = useState({
email: "",
password: "",
});
// UI状态
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({});
// 处理输入变化
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 清除对应字段的错误
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ""
}));
}
};
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = "邮箱是必填项";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "请输入有效的邮箱地址";
}
if (!formData.password) {
newErrors.password = "密码是必填项";
} else if (formData.password.length < 6) {
newErrors.password = "密码至少需要6个字符";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理表单提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const result = await login(formData.email, formData.password);
if (result.success) {
// 登录成功,跳转到首页
navigate("/home");
}
};
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
p={4}
>
<Box
w="full"
maxW="md"
p={8}
bg={colorMode === "dark" ? "gray.700" : "white"}
borderRadius="lg"
shadow="xl"
>
<VStack spacing={6}>
<Box textAlign="center">
<Heading size="lg" mb={2}>欢迎回来1</Heading>
<Text color="gray.500">请输入您的凭据登录</Text>
</Box>
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>邮箱地址</FormLabel>
<Input
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
size="lg"
/>
{errors.email && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.email}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>密码</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
placeholder="********"
value={formData.password}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.password && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.password}
</Text>
)}
</FormControl>
<Button
type="submit"
colorScheme="blue"
w="full"
size="lg"
isLoading={isLoading}
loadingText="登录中..."
>
{isLoading ? <Spinner size="sm" /> : "登录"}
</Button>
</VStack>
</form>
<VStack spacing={3}>
<Text fontSize="sm" textAlign="center">
还没有账户{" "}
<Link
color="blue.500"
onClick={() => navigate("/auth/signup")}
_hover={{ textDecoration: "underline" }}
>
立即注册
</Link>
</Text>
<Box textAlign="center">
<Link
color="gray.500"
fontSize="sm"
_hover={{ color: "blue.500" }}
>
忘记密码
</Link>
<Text color="gray.500" fontSize="sm" mt={2}>
还没有账户{" "}
<Link
color="blue.500"
fontWeight="medium"
_hover={{ textDecoration: "underline" }}
onClick={() => navigate('/auth/sign-up')}
>
立即注册
</Link>
</Text>
</Box>
</VStack>
</VStack>
</Box>
</Box>
);
}

View File

@@ -1,223 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
HStack,
Icon,
Input,
Link,
Switch,
Text,
useColorModeValue,
} from "@chakra-ui/react";
// Assets
import CoverImage from "assets/img/CoverImage.png";
import React from "react";
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
import AuthCover from "layouts/AuthCover";
function SignInCover() {
// Chakra color mode
const textColor = useColorModeValue("gray.400", "white");
const bgForm = useColorModeValue("white", "navy.800");
const titleColor = useColorModeValue("gray.700", "blue.500");
const colorIcons = useColorModeValue("gray.700", "white");
const bgIcons = useColorModeValue("trasnparent", "navy.700");
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
return (
<AuthCover image={CoverImage}>
<Flex
w="100%"
h="100%"
alignItems="center"
justifyContent="center"
mb="60px"
mt={{ base: "60px", md: "160px" }}
>
<Flex
zIndex="2"
direction="column"
w="445px"
background="transparent"
borderRadius="15px"
p="40px"
mx={{ base: "100px" }}
mb={{ base: "20px", md: "auto" }}
bg={bgForm}
boxShadow={useColorModeValue(
"0px 5px 14px rgba(0, 0, 0, 0.05)",
"unset"
)}
>
<Text
fontSize="xl"
color={textColor}
fontWeight="bold"
textAlign="center"
mb="22px"
>
Sign In with
</Text>
<HStack spacing="15px" justify="center" mb="22px">
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaApple}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaGoogle}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
</HStack>
<Text
fontSize="lg"
color="gray.400"
fontWeight="bold"
textAlign="center"
mb="22px"
>
or
</Text>
<FormControl>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Name
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="text"
placeholder="Your full name"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Password
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="password"
placeholder="Your password"
mb="24px"
size="lg"
/>
<FormControl display="flex" alignItems="center" mb="24px">
<Switch id="remember-login" colorScheme="blue" me="10px" />
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
Remember me
</FormLabel>
</FormControl>
<Button
fontSize="10px"
variant="dark"
fontWeight="bold"
w="100%"
h="45"
mb="24px"
>
SIGN IN
</Button>
</FormControl>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
mt="0px"
>
<Text color={textColor} fontWeight="medium">
Dont have an account?
<Link
color={titleColor}
as="span"
ms="5px"
href="#"
fontWeight="bold"
>
Sign up
</Link>
</Text>
</Flex>
</Flex>
</Flex>
</AuthCover>
);
}
export default SignInCover;

View File

@@ -1,538 +0,0 @@
// src/views/Authentication/SignIn/SignInIllustration.js - Session版本
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Button,
Flex,
FormControl,
Input,
Text,
Heading,
VStack,
HStack,
useToast,
Icon,
InputGroup,
InputRightElement,
IconButton,
Link as ChakraLink,
Center,
useDisclosure,
FormErrorMessage
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { FaMobile, FaLock } from "react-icons/fa";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
import PrivacyPolicyModal from "../../../components/PrivacyPolicyModal";
import UserAgreementModal from "../../../components/UserAgreementModal";
import AuthBackground from "../../../components/Auth/AuthBackground";
import AuthHeader from "../../../components/Auth/AuthHeader";
import AuthFooter from "../../../components/Auth/AuthFooter";
import VerificationCodeInput from "../../../components/Auth/VerificationCodeInput";
import WechatRegister from "../../../components/Auth/WechatRegister";
import { logger } from "../../../utils/logger";
export default function SignInIllustration() {
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
const { login, checkSession } = useAuth();
// 追踪组件挂载状态,防止内存泄漏
const isMountedRef = useRef(true);
// 页面状态
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
// 检查URL参数中的错误信息微信登录失败时
useEffect(() => {
const params = new URLSearchParams(location.search);
const error = params.get('error');
if (error) {
let errorMessage = '登录失败';
switch (error) {
case 'wechat_auth_failed':
errorMessage = '微信授权失败';
break;
case 'session_expired':
errorMessage = '会话已过期,请重新登录';
break;
case 'token_failed':
errorMessage = '获取微信授权失败';
break;
case 'userinfo_failed':
errorMessage = '获取用户信息失败';
break;
case 'login_failed':
errorMessage = '登录处理失败,请重试';
break;
default:
errorMessage = '登录失败,请重试';
}
toast({
title: "登录失败",
description: errorMessage,
status: "error",
duration: 5000,
isClosable: true,
});
// 清除URL参数
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}, [location, toast]);
// 传统登录数据
// 表单数据初始化
const [formData, setFormData] = useState({
username: "", // 用户名称
email: "", // 邮箱
phone: "", // 电话
password: "", // 密码
verificationCode: "", // 添加验证码字段
});
// 验证码登录状态 是否开启验证码
const [useVerificationCode, setUseVerificationCode] = useState(false);
// 密码展示状态
const [showPassword, setShowPassword] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false); // 验证码发送状态
const [sendingCode, setSendingCode] = useState(false); // 发送验证码状态
// 隐私政策弹窗状态
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
// 用户协议弹窗状态
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
// 输入框输入
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
// ========== 发送验证码逻辑 =============
// 倒计时效果
const [countdown, setCountdown] = useState(0);
useEffect(() => {
let timer;
let isMounted = true;
if (countdown > 0) {
timer = setInterval(() => {
if (isMounted) {
setCountdown(prev => prev - 1);
}
}, 1000);
} else if (countdown === 0 && isMounted) {
setVerificationCodeSent(false);
}
return () => {
isMounted = false;
if (timer) clearInterval(timer);
};
}, [countdown]);
// 发送验证码
const sendVerificationCode = async () => {
const credential = formData.phone;
const type = 'phone';
if (!credential) {
toast({
title: "请先输入手机号",
status: "warning",
duration: 3000,
});
return;
}
// 基本格式验证
if (!/^1[3-9]\d{9}$/.test(credential)) {
toast({
title: "请输入有效的手机号",
status: "warning",
duration: 3000,
});
return;
}
try {
setSendingCode(true);
const response = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
credential,
type,
purpose: 'login'
}),
});
// ✅ 安全检查:验证 response 存在
if (!response) {
throw new Error('网络请求失败,请检查网络连接');
}
const data = await response.json();
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 data 存在
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) {
toast({
title: "验证码已发送",
description: "验证码已发送到您的手机号",
status: "success",
duration: 3000,
});
setVerificationCodeSent(true);
setCountdown(60); // 60秒倒计时
} else {
throw new Error(data.error || '发送验证码失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setSendingCode(false);
}
}
};
// 验证码登录函数
const loginWithVerificationCode = async (credential, verificationCode, authLoginType) => {
try {
const response = await fetch('/api/auth/login-with-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
credential,
verification_code: verificationCode,
login_type: authLoginType
}),
});
// ✅ 安全检查:验证 response 存在
if (!response) {
throw new Error('网络请求失败,请检查网络连接');
}
const data = await response.json();
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) {
return { success: false, error: '操作已取消' };
}
// ✅ 安全检查:验证 data 存在
if (!data) {
throw new Error('服务器响应为空');
}
if (response.ok && data.success) {
// 更新认证状态
await checkSession();
if (isMountedRef.current) {
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
});
}
return { success: true };
} else {
throw new Error(data.error || '验证码登录失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "登录失败",
description: error.message || "请检查验证码是否正确",
status: "error",
duration: 3000,
});
}
return { success: false, error: error.message };
}
};
// 传统行业登陆
const handleTraditionalLogin = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
const credential = formData.phone;
const authLoginType = 'phone';
if (useVerificationCode) { // 验证码登陆
if (!credential || !formData.verificationCode) {
toast({
title: "请填写完整信息",
description: "手机号和验证码不能为空",
status: "warning",
duration: 3000,
});
return;
}
const result = await loginWithVerificationCode(credential, formData.verificationCode, authLoginType);
if (result.success) {
navigate("/home");
}
} else { // 密码登陆
if (!credential || !formData.password) {
toast({
title: "请填写完整信息",
description: `手机号和密码不能为空`,
status: "warning",
duration: 3000,
});
return;
}
const result = await login(credential, formData.password, authLoginType);
if (result.success) {
// ✅ 显示成功提示
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
isClosable: true,
});
navigate("/home");
} else {
// ❌ 显示错误提示
toast({
title: "登录失败",
description: result.error || "请检查您的登录信息",
status: "error",
duration: 3000,
isClosable: true,
});
}
}
} catch (error) {
logger.error('SignInIllustration', 'handleTraditionalLogin', error, {
phone: formData.phone ? formData.phone.substring(0, 3) + '****' + formData.phone.substring(7) : 'N/A',
useVerificationCode,
loginType: 'phone'
});
toast({
title: "登录失败",
description: error.message || "发生未预期的错误,请重试",
status: "error",
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 切换登录方式
const handleChangeMethod = () => {
setUseVerificationCode(!useVerificationCode);
// 切换到密码模式时清空验证码
if (useVerificationCode) {
setFormData(prev => ({ ...prev, verificationCode: "" }));
}
};
// 组件卸载时清理
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return (
<Flex minH="100vh" position="relative" overflow="hidden">
{/* 背景 */}
<AuthBackground />
{/* 主要内容 */}
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
{/* 登录卡片 */}
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
{/* 头部区域 */}
<AuthHeader title="欢迎回来" subtitle="登录价值前沿,继续您的投资之旅" />
{/* 左右布局 */}
<HStack spacing={8} align="stretch">
{/* 左侧:手机号登陆 - 80% 宽度 */}
<Box flex="4">
<form onSubmit={handleTraditionalLogin}>
<VStack spacing={4}>
<Heading size="md" color="gray.700" alignSelf="flex-start">
手机号登陆
</Heading>
<FormControl isRequired isInvalid={!!errors.phone}>
<Input
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="请输入11位手机号"
pr="2.5rem"
/>
<FormErrorMessage>{errors.phone}</FormErrorMessage>
</FormControl>
{/* 密码/验证码输入框 */}
{useVerificationCode ? (
<VerificationCodeInput
value={formData.verificationCode}
onChange={handleInputChange}
onSendCode={sendVerificationCode}
countdown={countdown}
isLoading={isLoading}
isSending={sendingCode}
error={errors.verificationCode}
colorScheme="green"
/>
) : (
<FormControl isRequired isInvalid={!!errors.password}>
<InputGroup>
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
pr="3rem"
placeholder="请输入密码"
_focus={{
borderColor: "blue.500",
boxShadow: "0 0 0 1px #667eea"
}}
/>
<InputRightElement width="3rem">
<IconButton
size="sm"
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password}</FormErrorMessage>
</FormControl>
)}
<AuthFooter
linkText="还没有账号,"
linkLabel="去注册"
linkTo="/auth/sign-up"
useVerificationCode={useVerificationCode}
onSwitchMethod={handleChangeMethod}
/>
<Button
type="submit"
width="100%"
size="lg"
colorScheme="green"
color="white"
borderRadius="lg"
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
}}
_active={{ transform: "translateY(0)" }}
isLoading={isLoading}
loadingText="登录中..."
fontWeight="bold"
cursor={"pointer"}
>
<Icon as={FaLock} mr={2} />登录
</Button>
</VStack>
</form>
</Box>
{/* 右侧:微信登陆 - 20% 宽度 */}
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<WechatRegister />
</Center>
</Box>
</HStack>
{/* 底部链接 */}
<VStack spacing={4} mt={6}>
{/* 协议同意勾选框 */}
<Text fontSize="sm" color="gray.600">
注册登录即表示阅读并同意{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
>
隐私政策
</ChakraLink>
</Text>
</VStack>
</Box>
</Flex>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
{/* 用户协议弹窗 */}
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
</Flex >
);
}

View File

@@ -1,254 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import React, { useState } from "react";
import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
Input,
Stack,
useColorModeValue,
Text,
Link,
InputGroup,
InputRightElement,
IconButton,
useToast,
Checkbox,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
export default function SignUpBasic() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
});
const navigate = useNavigate();
const toast = useToast();
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === "checkbox" ? checked : value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
toast({
title: "密码不匹配",
description: "请确保两次输入的密码相同",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
if (!formData.agreeToTerms) {
toast({
title: "请同意条款",
description: "请阅读并同意用户协议和隐私政策",
status: "error",
duration: 3000,
isClosable: true,
});
return;
}
setIsLoading(true);
// 模拟注册过程
setTimeout(() => {
setIsLoading(false);
toast({
title: "注册成功",
description: "欢迎加入价值前沿投资助手",
status: "success",
duration: 3000,
isClosable: true,
});
navigate("/home");
}, 1500);
};
return (
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
<Stack align="center">
<Heading fontSize="4xl" color="blue.600">
价小前投研
</Heading>
<Text fontSize="lg" color="gray.600">
创建您的账户
</Text>
</Stack>
<Box
rounded="lg"
bg={useColorModeValue("white", "gray.700")}
boxShadow="lg"
p={8}
>
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl id="username" isRequired>
<FormLabel>用户名</FormLabel>
<Input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="请输入您的用户名"
/>
</FormControl>
<FormControl id="email" isRequired>
<FormLabel>邮箱地址</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="请输入您的邮箱"
/>
</FormControl>
<FormControl id="password" isRequired>
<FormLabel>密码</FormLabel>
<InputGroup>
<Input
type={showPassword ? "text" : "password"}
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="请输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl id="confirmPassword" isRequired>
<FormLabel>确认密码</FormLabel>
<InputGroup>
<Input
type={showConfirmPassword ? "text" : "password"}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="请再次输入您的密码"
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl id="agreeToTerms">
<Checkbox
name="agreeToTerms"
isChecked={formData.agreeToTerms}
onChange={handleInputChange}
colorScheme="blue"
>
<Text fontSize="sm">
我已阅读并同意{" "}
<Link color="blue.600" href="#" isExternal>
用户协议
</Link>{" "}
{" "}
<Link color="blue.600" href="#" isExternal>
隐私政策
</Link>
</Text>
</Checkbox>
</FormControl>
<Stack spacing={10}>
<Button
type="submit"
bg="blue.600"
color="white"
_hover={{
bg: "blue.700",
}}
isLoading={isLoading}
loadingText="注册中..."
>
注册
</Button>
</Stack>
<Stack pt={6}>
<Text align="center">
已有账户?{" "}
<Link color="blue.600" onClick={() => navigate("/auth/signin")}>
立即登录
</Link>
</Text>
</Stack>
</Stack>
</form>
</Box>
</Stack>
</Flex>
);
}

View File

@@ -1,282 +0,0 @@
import React, { useState } from "react";
import {
Box,
Button,
FormControl,
FormLabel,
Input,
VStack,
Heading,
Text,
Link,
useColorMode,
InputGroup,
InputRightElement,
IconButton,
Spinner,
Checkbox,
HStack,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../../../contexts/AuthContext";
export default function SignUpCentered() {
const { colorMode } = useColorMode();
const navigate = useNavigate();
const { register, isLoading } = useAuth();
// 表单状态
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
});
// UI状态
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState({});
const [agreedToTerms, setAgreedToTerms] = useState(false);
// 处理输入变化
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// 清除对应字段的错误
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ""
}));
}
};
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = "姓名是必填项";
} else if (formData.name.trim().length < 2) {
newErrors.name = "姓名至少需要2个字符";
}
if (!formData.email) {
newErrors.email = "邮箱是必填项";
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = "请输入有效的邮箱地址";
}
if (!formData.password) {
newErrors.password = "密码是必填项";
} else if (formData.password.length < 6) {
newErrors.password = "密码至少需要6个字符";
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
newErrors.password = "密码必须包含大小写字母和数字";
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = "请确认密码";
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "两次输入的密码不一致";
}
if (!agreedToTerms) {
newErrors.terms = "请同意服务条款和隐私政策";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理表单提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const result = await register(
formData.name, // username
formData.email,
formData.password
);
if (result.success) {
// 注册成功,跳转到首页
navigate("/home");
}
};
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
p={4}
>
<Box
w="full"
maxW="md"
p={8}
bg={colorMode === "dark" ? "gray.700" : "white"}
borderRadius="lg"
shadow="xl"
>
<VStack spacing={6}>
<Box textAlign="center">
<Heading size="lg" mb={2}>创建账户</Heading>
<Text color="gray.500">加入价值前沿开启智能投资之旅</Text>
</Box>
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>姓名</FormLabel>
<Input
name="name"
placeholder="您的姓名"
value={formData.name}
onChange={handleInputChange}
size="lg"
/>
{errors.name && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.name}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel>邮箱地址</FormLabel>
<Input
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
size="lg"
/>
{errors.email && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.email}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>密码</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? "text" : "password"}
placeholder="********"
value={formData.password}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? "隐藏密码" : "显示密码"}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.password && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.password}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.confirmPassword}>
<FormLabel>确认密码</FormLabel>
<InputGroup size="lg">
<Input
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="********"
value={formData.confirmPassword}
onChange={handleInputChange}
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
/>
</InputRightElement>
</InputGroup>
{errors.confirmPassword && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.confirmPassword}
</Text>
)}
</FormControl>
<FormControl isInvalid={!!errors.terms}>
<HStack spacing={3}>
<Checkbox
isChecked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
colorScheme="blue"
>
<Text fontSize="sm">
我同意{" "}
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
服务条款
</Link>
{" "}{" "}
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
隐私政策
</Link>
</Text>
</Checkbox>
</HStack>
{errors.terms && (
<Text color="red.500" fontSize="sm" mt={1}>
{errors.terms}
</Text>
)}
</FormControl>
<Button
type="submit"
colorScheme="blue"
w="full"
size="lg"
isLoading={isLoading}
loadingText="注册中..."
>
{isLoading ? <Spinner size="sm" /> : "创建账户"}
</Button>
</VStack>
</form>
<VStack spacing={3}>
<Text fontSize="sm" textAlign="center">
已有账户{" "}
<Link
color="blue.500"
onClick={() => navigate("/auth/signin")}
_hover={{ textDecoration: "underline" }}
>
立即登录
</Link>
</Text>
</VStack>
</VStack>
</Box>
</Box>
);
}

View File

@@ -1,234 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Button,
Flex,
FormControl,
FormLabel,
HStack,
Icon,
Input,
Link,
Switch,
Text,
useColorModeValue,
} from "@chakra-ui/react";
// Assets
import CoverImage from "assets/img/CoverImage.png";
import React from "react";
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
import AuthCover from "layouts/AuthCover";
function SignUpCover() {
// Chakra color mode
const textColor = useColorModeValue("gray.400", "white");
const bgForm = useColorModeValue("white", "navy.800");
const titleColor = useColorModeValue("gray.700", "blue.500");
const colorIcons = useColorModeValue("gray.700", "white");
const bgIcons = useColorModeValue("trasnparent", "navy.700");
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
return (
<AuthCover image={CoverImage}>
<Flex
w="100%"
h="100%"
alignItems="center"
justifyContent="center"
mb="60px"
mt={{ base: "60px", md: "160px" }}
>
<Flex
zIndex="2"
direction="column"
w="445px"
background="transparent"
borderRadius="15px"
p="40px"
mx={{ base: "100px" }}
mb={{ base: "20px", md: "auto" }}
bg={bgForm}
boxShadow={useColorModeValue(
"0px 5px 14px rgba(0, 0, 0, 0.05)",
"unset"
)}
>
<Text
fontSize="xl"
color={textColor}
fontWeight="bold"
textAlign="center"
mb="22px"
>
Sign In with
</Text>
<HStack spacing="15px" justify="center" mb="22px">
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaApple}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
<Flex
justify="center"
align="center"
w="75px"
h="75px"
borderRadius="8px"
border={useColorModeValue("1px solid", "0px")}
borderColor="gray.200"
cursor="pointer"
transition="all .25s ease"
bg={bgIcons}
_hover={{ bg: bgIconsHover }}
>
<Link href="#">
<Icon
as={FaGoogle}
color={colorIcons}
w="30px"
h="30px"
_hover={{ filter: "brightness(120%)" }}
/>
</Link>
</Flex>
</HStack>
<Text
fontSize="lg"
color="gray.400"
fontWeight="bold"
textAlign="center"
mb="22px"
>
or
</Text>
<FormControl>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Name
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="text"
placeholder="Your full name"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Email
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="email"
placeholder="Your full email adress"
mb="24px"
size="lg"
/>
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
Password
</FormLabel>
<Input
variant="auth"
fontSize="sm"
ms="4px"
type="password"
placeholder="Your password"
mb="24px"
size="lg"
/>
<FormControl display="flex" alignItems="center" mb="24px">
<Switch id="remember-login" colorScheme="blue" me="10px" />
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
Remember me
</FormLabel>
</FormControl>
<Button
fontSize="10px"
variant="dark"
fontWeight="bold"
w="100%"
h="45"
mb="24px"
>
SIGN IN
</Button>
</FormControl>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
mt="0px"
>
<Text color={textColor} fontWeight="medium">
Dont have an account?
<Link
color={titleColor}
as="span"
ms="5px"
href="#"
fontWeight="bold"
>
Sign up
</Link>
</Text>
</Flex>
</Flex>
</Flex>
</AuthCover>
);
}
export default SignUpCover;

View File

@@ -1,445 +0,0 @@
// src\views\Authentication\SignUp/SignUpIllustration.js
import React, { useState, useEffect, useRef } from "react";
import { getApiBase } from '../../../utils/apiConfig';
import {
Box,
Button,
Flex,
FormControl,
Input,
Text,
Heading,
VStack,
HStack,
useToast,
InputGroup,
InputRightElement,
IconButton,
Center,
FormErrorMessage,
Link as ChakraLink,
useDisclosure
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import AuthBackground from '../../../components/Auth/AuthBackground';
import AuthHeader from '../../../components/Auth/AuthHeader';
import AuthFooter from '../../../components/Auth/AuthFooter';
import VerificationCodeInput from '../../../components/Auth/VerificationCodeInput';
import WechatRegister from '../../../components/Auth/WechatRegister';
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
import UserAgreementModal from '../../../components/UserAgreementModal';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
export default function SignUpPage() {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
username: "",
email: "",
phone: "",
password: "",
confirmPassword: "",
verificationCode: ""
});
const navigate = useNavigate();
const toast = useToast();
// 追踪组件挂载状态,防止内存泄漏
const isMountedRef = useRef(true);
// 隐私政策弹窗状态
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
// 用户协议弹窗状态
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
// 验证码登录状态 是否开启验证码
const [useVerificationCode, setUseVerificationCode] = useState(false);
// 切换注册方式
const handleChangeMethod = () => {
setUseVerificationCode(!useVerificationCode);
// 切换到密码模式时清空验证码
if (useVerificationCode) {
setFormData(prev => ({ ...prev, verificationCode: "" }));
}
};
// 发送验证码
const sendVerificationCode = async () => {
const contact = formData.phone;
const endpoint = "send-sms-code";
const fieldName = "phone";
if (!contact) {
toast({
title: "请输入手机号",
status: "warning",
duration: 2000,
});
return;
}
if (!/^1[3-9]\d{9}$/.test(contact)) {
toast({
title: "请输入正确的手机号",
status: "warning",
duration: 2000,
});
return;
}
try {
setIsLoading(true);
const response = await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
[fieldName]: contact
}, {
timeout: 10000 // 添加10秒超时
});
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 response 和 data 存在
if (!response || !response.data) {
throw new Error('服务器响应为空');
}
toast({
title: "验证码已发送",
description: "请查收短信",
status: "success",
duration: 3000,
});
setCountdown(60);
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送失败",
description: error.response?.data?.error || error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
// 倒计时效果
useEffect(() => {
let isMounted = true;
if (countdown > 0) {
const timer = setTimeout(() => {
if (isMounted) {
setCountdown(countdown - 1);
}
}, 1000);
return () => {
isMounted = false;
clearTimeout(timer);
};
}
}, [countdown]);
// 表单验证
const validateForm = () => {
const newErrors = {};
// 手机号验证(两种方式都需要)
if (!formData.phone) {
newErrors.phone = "请输入手机号";
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
newErrors.phone = "请输入正确的手机号";
}
if (useVerificationCode) {
// 验证码注册方式:只验证手机号和验证码
if (!formData.verificationCode) {
newErrors.verificationCode = "请输入验证码";
}
} else {
// 密码注册方式:验证用户名、密码和确认密码
if (!formData.password || formData.password.length < 6) {
newErrors.password = "密码至少6个字符";
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "两次密码不一致";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理注册提交
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
let endpoint, data;
if (useVerificationCode) {
// 验证码注册:只发送手机号和验证码
endpoint = "/api/auth/register/phone-code";
data = {
phone: formData.phone,
code: formData.verificationCode
};
} else {
// 密码注册:发送手机号、用户名和密码
endpoint = "/api/auth/register/phone";
data = {
phone: formData.phone,
username: formData.username,
password: formData.password
};
}
const response = await axios.post(`${API_BASE_URL}${endpoint}`, data, {
timeout: 10000 // 添加10秒超时
});
// 组件卸载后不再执行后续操作
if (!isMountedRef.current) return;
// ✅ 安全检查:验证 response 和 data 存在
if (!response || !response.data) {
throw new Error('注册请求失败:服务器响应为空');
}
toast({
title: "注册成功",
description: "即将跳转到登录页面",
status: "success",
duration: 2000,
});
setTimeout(() => {
if (isMountedRef.current) {
navigate("/auth/sign-in");
}
}, 2000);
} catch (error) {
if (isMountedRef.current) {
toast({
title: "注册失败",
description: error.response?.data?.error || error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
// 组件卸载时清理
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// 公用的用户名和密码输入框组件
const commonAuthFields = (
<VStack spacing={4} width="100%">
<FormControl isRequired isInvalid={!!errors.password}>
<InputGroup>
<Input
name="password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={handleInputChange}
placeholder="设置密码至少6个字符"
pr="3rem"
/>
<InputRightElement width="3rem">
<IconButton
size="sm"
variant="ghost"
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password}</FormErrorMessage>
</FormControl>
<FormControl isRequired isInvalid={!!errors.confirmPassword}>
<Input
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="再次输入密码"
/>
<FormErrorMessage>{errors.confirmPassword}</FormErrorMessage>
</FormControl>
</VStack>
);
return (
<Flex minH="100vh" position="relative" overflow="hidden">
{/* 背景 */}
<AuthBackground />
{/* 主要内容 */}
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
{/* 头部区域 */}
<AuthHeader title="创建账户" subtitle="加入价值前沿,开启投资新征程" />
{/* 左右布局 */}
<HStack spacing={8} align="stretch">
{/* 左侧:手机号注册 - 80% 宽度 */}
<Box flex="4">
<form onSubmit={handleSubmit}>
<VStack spacing={4}>
<Heading size="md" color="gray.700" alignSelf="flex-start">
注册
</Heading>
<FormControl isRequired isInvalid={!!errors.phone}>
<Input
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="请输入11位手机号"
pr="2.5rem"
/>
<FormErrorMessage>{errors.phone}</FormErrorMessage>
</FormControl>
{/* 表单字段区域 */}
<Box width="100%">
{
useVerificationCode ? (
<VStack spacing={4} width="100%">
<VerificationCodeInput
value={formData.verificationCode}
onChange={handleInputChange}
onSendCode={sendVerificationCode}
countdown={countdown}
isLoading={isLoading}
isSending={isLoading && countdown === 0}
error={errors.verificationCode}
colorScheme="green"
/>
{/* 隐藏的占位元素,保持与密码模式相同的高度 */}
<Box height="40px" width="100%" visibility="hidden" />
</VStack>
) : (
<>
{commonAuthFields}
</>
)
}
</Box>
<AuthFooter
linkText="已有账号?"
linkLabel="去登录"
linkTo="/auth/sign-in"
useVerificationCode={useVerificationCode}
onSwitchMethod={handleChangeMethod}
/>
<Button
type="submit"
width="100%"
size="lg"
colorScheme="green"
color="white"
borderRadius="lg"
_hover={{
transform: "translateY(-2px)",
boxShadow: "lg"
}}
_active={{ transform: "translateY(0)" }}
isLoading={isLoading}
loadingText="注册中..."
fontWeight="bold"
cursor="pointer"
>
注册
</Button>
{/* 协议同意文本 */}
<Text fontSize="sm" color="gray.600" textAlign="center" width="100%">
注册登录即表示阅读并同意{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onUserAgreementModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
cursor="pointer"
>
用户协议
</ChakraLink>
{" "}{" "}
<ChakraLink
color="blue.500"
fontSize="sm"
onClick={onPrivacyModalOpen}
textDecoration="underline"
_hover={{ color: "blue.600" }}
cursor="pointer"
>
隐私政策
</ChakraLink>
</Text>
</VStack>
</form>
</Box>
{/* 右侧:微信注册 - 20% 宽度 */}
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<WechatRegister />
</Center>
</Box>
</HStack>
</Box>
</Flex>
{/* 隐私政策弹窗 */}
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
{/* 用户协议弹窗 */}
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
</Flex>
);
}

View File

@@ -0,0 +1,441 @@
// src/views/Community/components/DynamicNewsCard.js
// 横向滚动事件卡片组件(实时要闻·动态追踪)
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
Center,
Spinner,
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
import UnifiedSearchBox from './UnifiedSearchBox';
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
/**
* 实时要闻·动态追踪 - 事件展示卡片组件
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
* @param {boolean} loading - 加载状态
* @param {number} total - 服务端总数量
* @param {number} cachedCount - 已缓存数量
* @param {Object} filters - 筛选条件
* @param {Array} popularKeywords - 热门关键词
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onSearch - 搜索回调
* @param {Function} onSearchFocus - 搜索框获得焦点回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
allCachedEvents = [],
loading,
total = 0,
cachedCount = 0,
filters = {},
popularKeywords = [],
lastUpdateTime,
onSearch,
onSearchFocus,
onEventClick,
onViewDetail,
...rest
}, ref) => {
const dispatch = useDispatch();
const toast = useToast();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
// 关注按钮点击处理
const handleToggleFollow = useCallback((eventId) => {
dispatch(toggleEventFollow(eventId));
}, [dispatch]);
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
const [currentPage, setCurrentPage] = useState(1); // 当前页码
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
// 根据模式决定每页显示数量
const pageSize = mode === 'carousel' ? 5 : 10;
// 计算总页数(基于服务端总数据量)
const totalPages = Math.ceil(total / pageSize) || 1;
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
}, [allCachedEvents, currentPage, pageSize]);
// 翻页处理(智能预加载)
const handlePageChange = useCallback(async (newPage) => {
// 🔍 诊断日志 - 记录翻页开始状态
console.log('[handlePageChange] 开始翻页', {
currentPage,
newPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 0. 首先检查目标页数据是否已完整缓存
const targetPageStartIndex = (newPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
const isTargetPageCached = validTargetData.length >= expectedCount;
console.log('[handlePageChange] 目标页缓存检查', {
newPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
// 2. 计算预加载范围
let preloadRange;
if (isSequentialNavigation) {
// 连续翻页前后各2页共5页
const start = Math.max(1, newPage - 2);
const end = Math.min(totalPages, newPage + 2);
preloadRange = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
);
} else {
// 跳转翻页:只加载当前页
preloadRange = [newPage];
}
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
const missingPages = preloadRange.filter(page => {
const pageStartIndex = (page - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
// 如果该页超出数组范围,说明未缓存
if (pageEndIndex > allCachedEvents.length) {
console.log(`[missingPages] 页面${page}超出数组范围`, {
pageStartIndex,
pageEndIndex,
allCachedEventsLength: allCachedEvents.length
});
return true;
}
// 检查该页的数据是否包含 null 占位符或数据不足
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
const validData = pageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - pageStartIndex);
const hasNullOrIncomplete = validData.length < expectedCount;
console.log(`[missingPages] 页面${page}检查`, {
pageStartIndex,
pageEndIndex,
pageDataLength: pageData.length,
validDataLength: validData.length,
expectedCount,
hasNullOrIncomplete
});
return hasNullOrIncomplete;
});
console.log('[handlePageChange] 缺失页面检测完成', {
preloadRange,
missingPages,
missingPagesCount: missingPages.length
});
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
currentPage,
newPage,
缺失页面: missingPages,
目标页已缓存: true
});
// 立即切换页码(用户无感知延迟)
setCurrentPage(newPage);
// 在后台静默预加载其他缺失页面(拆分为单页请求)
try {
console.log('[DynamicNewsCard] 开始后台预加载', {
缺失页面: missingPages,
每页数量: pageSize
});
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
}
console.log('[DynamicNewsCard] 后台预加载全部完成', {
预加载页面: missingPages
});
} catch (error) {
console.error('[DynamicNewsCard] 后台预加载失败', error);
// 静默失败,不影响用户体验
}
return; // 提前返回,不执行下面的加载逻辑
}
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
if (missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页未缓存显示loading', {
currentPage,
newPage,
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
预加载范围: preloadRange,
缺失页面: missingPages,
每页数量: pageSize,
目标页已缓存: false
});
try {
// 设置加载状态(显示"正在加载第X页..."
setLoadingPage(newPage);
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
console.log(`[DynamicNewsCard] 开始加载第 ${page}`);
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize, // 传递原始 pageSize用于正确计算索引
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
}
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
缺失页面: missingPages
});
// 数据加载成功后才更新当前页码
setCurrentPage(newPage);
} catch (error) {
console.error('[DynamicNewsCard] 翻页加载失败', error);
// 显示错误提示
toast({
title: '加载失败',
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
status: 'error',
duration: 3000,
isClosable: true,
position: 'top'
});
// 加载失败时不更新页码,保持在当前页
} finally {
// 清除加载状态
setLoadingPage(null);
}
} else if (missingPages.length === 0) {
// 只有在确实不需要加载时才直接切换
console.log('[handlePageChange] 无需加载,直接切换', {
currentPage,
newPage,
preloadRange,
missingPages,
reason: '所有页面均已缓存'
});
setCurrentPage(newPage);
} else {
// 理论上不应该到这里missingPages.length > 0 但 hasMore=false
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
missingPages,
hasMore,
currentPage,
newPage,
total,
cachedCount
});
// 尝试切换页码,但可能会显示空数据
setCurrentPage(newPage);
toast({
title: '数据不完整',
description: `${newPage} 页数据可能不完整`,
status: 'warning',
duration: 2000,
isClosable: true,
position: 'top'
});
}
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
// 模式切换处理
const handleModeToggle = useCallback((newMode) => {
if (newMode === mode) return;
setMode(newMode);
setCurrentPage(1);
const newPageSize = newMode === 'carousel' ? 5 : 10;
// 检查第1页的数据是否完整排除 null
const firstPageData = allCachedEvents.slice(0, newPageSize);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
// 如果第1页数据完整不发起请求直接切换
}, [mode, allCachedEvents, total, dispatch]);
// 初始加载
useEffect(() => {
if (allCachedEvents.length === 0) {
dispatch(fetchDynamicNews({
page: 1,
per_page: 5,
pageSize: 5, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
}, [dispatch, allCachedEvents.length]);
// 默认选中第一个事件
useEffect(() => {
if (currentPageEvents.length > 0 && !selectedEvent) {
setSelectedEvent(currentPageEvents[0]);
}
}, [currentPageEvents, selectedEvent]);
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时要闻·动态追踪</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="red">实时</Badge>
<Badge colorScheme="green">盘中</Badge>
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
{/* 搜索和筛选组件 */}
<Box mt={4}>
<UnifiedSearchBox
onSearch={onSearch}
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
/>
</Box>
</CardHeader>
{/* 主体内容 */}
<CardBody position="relative" pt={0}>
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
{currentPageEvents && currentPageEvents.length > 0 ? (
<EventScrollList
events={currentPageEvents}
selectedEvent={selectedEvent}
onEventSelect={setSelectedEvent}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
loading={loadingPage !== null}
loadingPage={loadingPage}
mode={mode}
onModeChange={handleModeToggle}
eventFollowStatus={eventFollowStatus}
onToggleFollow={handleToggleFollow}
hasMore={hasMore}
/>
) : !loading ? (
/* Empty 状态 - 只在非加载且无数据时显示 */
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
) : (
/* 首次加载状态 */
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
)}
{/* 详情面板 - 始终显示(如果有选中事件) */}
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
<Box mt={6}>
<DynamicNewsDetailPanel event={selectedEvent} />
</Box>
)}
</CardBody>
</Card>
);
});
DynamicNewsCard.displayName = 'DynamicNewsCard';
export default DynamicNewsCard;

View File

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

View File

@@ -0,0 +1,211 @@
// src/views/Community/components/DynamicNewsCard/PaginationControl.js
// 分页控制器组件
import React, { useState } from 'react';
import {
Box,
HStack,
Button,
Input,
Text,
IconButton,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
import {
ChevronLeftIcon,
ChevronRightIcon,
} from '@chakra-ui/icons';
/**
* 分页控制器组件
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {Function} onPageChange - 页码改变回调
*/
const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
const [jumpPage, setJumpPage] = useState('');
const toast = useToast();
const buttonBg = useColorModeValue('white', 'gray.700');
const activeBg = useColorModeValue('blue.500', 'blue.400');
const activeColor = useColorModeValue('white', 'white');
const borderColor = useColorModeValue('gray.300', 'gray.600');
const hoverBg = useColorModeValue('gray.100', 'gray.600');
// 生成页码数字列表(智能省略)
const getPageNumbers = () => {
const pageNumbers = [];
const maxVisible = 5; // 最多显示5个页码精简版
if (totalPages <= maxVisible) {
// 总页数少,显示全部
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// 总页数多,使用省略号
if (currentPage <= 3) {
// 当前页在前面
for (let i = 1; i <= 4; i++) {
pageNumbers.push(i);
}
pageNumbers.push('...');
pageNumbers.push(totalPages);
} else if (currentPage >= totalPages - 2) {
// 当前页在后面
pageNumbers.push(1);
pageNumbers.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// 当前页在中间
pageNumbers.push(1);
pageNumbers.push('...');
pageNumbers.push(currentPage);
pageNumbers.push('...');
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
// 处理页码跳转
const handleJump = () => {
const page = parseInt(jumpPage, 10);
if (isNaN(page)) {
toast({
title: '请输入有效的页码',
status: 'warning',
duration: 2000,
isClosable: true,
});
return;
}
if (page < 1 || page > totalPages) {
toast({
title: `页码范围1 - ${totalPages}`,
status: 'warning',
duration: 2000,
isClosable: true,
});
return;
}
onPageChange(page);
setJumpPage('');
};
// 处理回车键
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleJump();
}
};
const pageNumbers = getPageNumbers();
return (
<Box mb={3}>
<HStack spacing={1.5} justify="center" flexWrap="wrap">
{/* 上一页按钮 */}
<IconButton
icon={<ChevronLeftIcon />}
size="xs"
onClick={() => onPageChange(currentPage - 1)}
isDisabled={currentPage === 1}
bg={buttonBg}
borderWidth="1px"
borderColor={borderColor}
_hover={{ bg: hoverBg }}
aria-label="上一页"
title="上一页"
/>
{/* 数字页码列表 */}
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<Text
key={`ellipsis-${index}`}
px={1}
fontSize="xs"
color="gray.500"
>
...
</Text>
);
}
return (
<Button
key={page}
size="xs"
onClick={() => onPageChange(page)}
bg={currentPage === page ? activeBg : buttonBg}
color={currentPage === page ? activeColor : undefined}
borderWidth="1px"
borderColor={currentPage === page ? activeBg : borderColor}
_hover={{
bg: currentPage === page ? activeBg : hoverBg,
}}
minW="28px"
>
{page}
</Button>
);
})}
{/* 下一页按钮 */}
<IconButton
icon={<ChevronRightIcon />}
size="xs"
onClick={() => onPageChange(currentPage + 1)}
isDisabled={currentPage === totalPages}
bg={buttonBg}
borderWidth="1px"
borderColor={borderColor}
_hover={{ bg: hoverBg }}
aria-label="下一页"
title="下一页"
/>
{/* 分隔线 */}
<Box w="1px" h="20px" bg={borderColor} mx={1.5} />
{/* 输入框跳转 */}
<HStack spacing={1.5}>
<Text fontSize="xs" color="gray.600">
跳转到
</Text>
<Input
size="xs"
width="50px"
type="number"
min={1}
max={totalPages}
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="页"
bg={buttonBg}
borderColor={borderColor}
/>
<Button
size="xs"
colorScheme="blue"
onClick={handleJump}
>
跳转
</Button>
</HStack>
</HStack>
</Box>
);
};
export default PaginationControl;

View File

@@ -0,0 +1,60 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js
// 可折叠模块标题组件
import React from 'react';
import {
Flex,
HStack,
Heading,
Badge,
IconButton,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
/**
* 可折叠模块标题组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
*/
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
return (
<Flex
justify="space-between"
align="center"
cursor="pointer"
onClick={onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
<Heading size="sm" color={headingColor}>
{title}
</Heading>
{count !== null && (
<Badge colorScheme="blue" borderRadius="full">
{count}
</Badge>
)}
</HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
</Flex>
);
};
export default CollapsibleHeader;

View File

@@ -0,0 +1,41 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件
import React from 'react';
import {
Box,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
/**
* 通用可折叠区块组件
* @param {Object} props
* @param {string} props.title - 标题文本
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.children - 子内容
*/
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={onToggle}
count={count}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{children}
</Box>
</Collapse>
</Box>
);
};
export default CollapsibleSection;

View File

@@ -0,0 +1,205 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Card,
CardBody,
VStack,
Text,
Spinner,
Center,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import EventHeaderInfo from './EventHeaderInfo';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
/**
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
*/
const DynamicNewsDetailPanel = ({ event }) => {
const dispatch = useDispatch();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
const toast = useToast();
// 从 Redux 读取关注状态
const eventFollowStatus = useSelector(selectEventFollowStatus);
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
// 使用 Hook 获取实时数据
const {
stocks,
quotes,
eventDetail,
historicalEvents,
expectationScore,
loading
} = useEventStocks(event?.id, event?.created_at);
// 折叠状态管理
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
if (!event?.id) return;
dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]);
// 切换自选股
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
try {
const newWatchlist = new Set(watchlistSet);
if (isInWatchlist) {
newWatchlist.delete(stockCode);
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
newWatchlist.add(stockCode);
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
}
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '操作失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
}, [watchlistSet, toast]);
// 空状态
if (!event) {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<Text color={textColor} textAlign="center">
请选择一个事件查看详情
</Text>
</CardBody>
</Card>
);
}
const importance = getImportanceConfig(event.importance);
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部信息区 */}
<EventHeaderInfo
event={event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
{/* 事件描述 */}
<EventDescriptionSection description={event.description} />
{/* 相关概念 */}
<RelatedConceptsSection
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
/>
{/* 相关股票(可折叠) */}
{loading.stocks || loading.quotes ? (
<Center py={4}>
<Spinner size="md" color="blue.500" />
<Text ml={2} color={textColor}>加载股票数据中...</Text>
</Center>
) : (
<RelatedStocksSection
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isOpen={isStocksOpen}
onToggle={() => setIsStocksOpen(!isStocksOpen)}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
{/* 历史事件对比(可折叠) */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
count={historicalEvents?.length || 0}
>
{loading.historicalEvents ? (
<Center py={4}>
<Spinner size="sm" color="blue.500" />
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
</Center>
) : (
<HistoricalEvents
events={historicalEvents || []}
/>
)}
</CollapsibleSection>
{/* 传导链分析(可折叠) */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
>
<TransmissionChainAnalysis
eventId={event.id}
eventService={eventService}
/>
</CollapsibleSection>
</VStack>
</CardBody>
</Card>
);
};
export default DynamicNewsDetailPanel;

View File

@@ -0,0 +1,42 @@
// src/views/Community/components/DynamicNewsDetail/EventDescriptionSection.js
// 事件描述区组件
import React from 'react';
import {
Box,
Heading,
Text,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 事件描述区组件
* @param {Object} props
* @param {string} props.description - 事件描述文本
*/
const EventDescriptionSection = ({ description }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const textColor = useColorModeValue('gray.600', 'gray.400');
// 如果没有描述,不渲染
if (!description) {
return null;
}
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 事件描述 */}
<Box>
<Heading size="sm" color={headingColor} mb={2}>
事件描述
</Heading>
<Text fontSize="sm" color={textColor} lineHeight="tall">
{description}
</Text>
</Box>
</Box>
);
};
export default EventDescriptionSection;

View File

@@ -0,0 +1,131 @@
// src/views/Community/components/DynamicNewsDetail/EventHeaderInfo.js
// 事件头部信息区组件
import React from 'react';
import {
Box,
Flex,
HStack,
Heading,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import moment from 'moment';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 事件头部信息区组件
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
// 格式化涨跌幅数字
const formatChange = (value) => {
if (value === null || value === undefined) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${value.toFixed(2)}%`;
};
return (
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
{/* 粉色圆角标签(左上角绝对定位) */}
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
<Box
position="absolute"
top="-8px"
left="-8px"
bg="pink.500"
color="white"
px={3}
py={1}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
boxShadow="md"
zIndex={1}
>
{formatChange(event.related_avg_chg)}
</Box>
)}
{/* 第一行:标题 + 关注按钮 */}
<Flex align="center" justify="space-between" mb={3} gap={4}>
{/* 标题 */}
<Heading size="md" color={headingColor} flex={1}>
{event.title}
</Heading>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</Flex>
{/* 第二行:浏览数 + 日期 */}
<Flex align="left" mb={3} gap={4}>
{/* 浏览数 */}
<HStack spacing={1}>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}次浏览
</Text>
</HStack>
{/* 日期 */}
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
{moment(event.created_at).format('YYYY年MM月DD日')}
</Text>
</Flex>
{/* 第三行:涨跌幅指标 + 重要性文本 */}
<HStack spacing={3} align="center">
<Box maxW="500px">
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
/>
</Box>
{/* 重要性文本 */}
<Box
bg={importance.bgColor}
borderWidth="2px"
borderColor={importance.badgeBg}
px={2}
py={1}
borderRadius="md"
>
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
重要性{getImportanceText()}
</Text>
</Box>
</HStack>
</Box>
);
};
export default EventHeaderInfo;

View File

@@ -0,0 +1,184 @@
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import moment from 'moment';
import {
fetchKlineData,
getCacheKey,
klineDataCache
} from '../StockDetailPanel/utils/klineDataCache';
/**
* 迷你K线图组件
* 显示股票的K线走势蜡烛图支持事件时间标记
*
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @returns {JSX.Element}
*/
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
const loadedRef = useRef(false);
const dataFetchedRef = useRef(false);
// 稳定的事件时间
const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (!stockCode) {
setData([]);
loadedRef.current = false;
dataFetchedRef.current = false;
return;
}
if (dataFetchedRef.current) {
return;
}
// 检查缓存K线图使用 'daily' 类型)
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
const cachedData = klineDataCache.get(cacheKey);
if (cachedData && cachedData.length > 0) {
setData(cachedData);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
dataFetchedRef.current = true;
setLoading(true);
// 获取日K线数据
fetchKlineData(stockCode, stableEventTime, 'daily')
.then((result) => {
if (mountedRef.current) {
setData(result);
setLoading(false);
loadedRef.current = true;
}
})
.catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime]);
const chartOption = useMemo(() => {
// 提取K线数据 [open, close, low, high]
const klineData = data
.filter(item => item.open && item.close && item.low && item.high)
.map(item => [item.open, item.close, item.low, item.high]);
// 日K线使用 date 字段
const dates = data.map(item => item.date || item.time);
const hasData = klineData.length > 0;
if (!hasData) {
return {
title: {
text: loading ? '加载中...' : '无数据',
left: 'center',
top: 'middle',
textStyle: { color: '#999', fontSize: 10 }
}
};
}
// 计算事件时间标记
let eventMarkLineData = [];
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
try {
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
const eventIdx = dates.findIndex(d => {
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
return dateStr.includes(eventDate);
});
if (eventIdx >= 0) {
eventMarkLineData.push({
xAxis: eventIdx,
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
label: { show: false }
});
}
} catch (e) {
// 忽略异常
}
}
return {
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
xAxis: {
type: 'category',
data: dates,
show: false,
boundaryGap: true
},
yAxis: {
type: 'value',
show: false,
scale: true
},
series: [{
type: 'candlestick',
data: klineData,
itemStyle: {
color: '#ef5350', // 涨(阳线)
color0: '#26a69a', // 跌(阴线)
borderColor: '#ef5350', // 涨(边框)
borderColor0: '#26a69a' // 跌(边框)
},
barWidth: '60%',
markLine: {
silent: true,
symbol: 'none',
label: { show: false },
data: eventMarkLineData
}
}],
tooltip: { show: false },
animation: false
};
}, [data, loading, stableEventTime]);
return (
<div
style={{
width: 140,
height: 40,
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
</div>
);
}, (prevProps, nextProps) => {
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
});
export default MiniKLineChart;

View File

@@ -0,0 +1,94 @@
// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js
// Mini 折线图组件(用于股票卡片)
import React from 'react';
import { Box } from '@chakra-ui/react';
/**
* Mini 折线图组件
* @param {Object} props
* @param {Array<number>} props.data - 价格走势数据数组15个数据点前5+中5+后5
* @param {number} props.width - 图表宽度默认180
* @param {number} props.height - 图表高度默认60
*/
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
if (!data || data.length === 0) {
return null;
}
// 计算最大值和最小值,用于归一化
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1; // 防止除以0
// 将数据点转换为 SVG 路径坐标
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
// 构建 SVG 路径字符串
const pathD = `M ${points.join(' L ')}`;
// 判断整体趋势(比较第一个和最后一个值)
const isPositive = data[data.length - 1] >= data[0];
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
// 创建渐变填充区域路径
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
return (
<Box width={`${width}px`} height={`${height}px`}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<defs>
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 填充区域 */}
<path
d={fillPathD}
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
/>
{/* 折线 */}
<path
d={pathD}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 垂直分隔线(标记三个时间段) */}
{/* 前一天和当天之间 */}
<line
x1={width / 3}
y1={0}
x2={width / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
{/* 当天和后一天之间 */}
<line
x1={(width * 2) / 3}
y1={0}
x2={(width * 2) / 3}
y2={height}
stroke="#E2E8F0"
strokeWidth="1"
strokeDasharray="2,2"
/>
</svg>
</Box>
);
};
export default MiniLineChart;

View File

@@ -0,0 +1,79 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/ConceptStockItem.js
// 概念股票列表项组件
import React from 'react';
import {
Box,
HStack,
Text,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 概念股票列表项组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* - stock_name: 股票名称
* - stock_code: 股票代码
* - change_pct: 涨跌幅
* - reason: 关联原因
*/
const ConceptStockItem = ({ stock }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
const stockChangePct = parseFloat(stock.change_pct);
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
// 处理股票详情跳转
const handleStockClick = (e) => {
e.stopPropagation(); // 阻止事件冒泡到概念卡片
const cleanCode = stock.stock_code.replace(/\.(SZ|SH)$/i, '');
window.open(`https://valuefrontier.cn/company?scode=${cleanCode}`, '_blank');
};
return (
<Box
p={2}
borderRadius="md"
bg={sectionBg}
fontSize="xs"
cursor="pointer"
onClick={handleStockClick}
_hover={{
bg: useColorModeValue('gray.100', 'gray.700'),
transform: 'translateX(4px)',
}}
transition="all 0.2s"
>
<HStack justify="space-between" mb={1}>
<HStack spacing={2}>
<Text fontWeight="semibold" color={conceptNameColor}>
{stock.stock_name}
</Text>
<Badge size="sm" variant="outline">
{stock.stock_code}
</Badge>
</HStack>
{stock.change_pct && (
<Badge
colorScheme={stockChangeColor}
fontSize="xs"
>
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
</Badge>
)}
</HStack>
{stock.reason && (
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
{stock.reason}
</Text>
)}
</Box>
);
};
export default ConceptStockItem;

View File

@@ -0,0 +1,153 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/DetailedConceptCard.js
// 详细概念卡片组件
import React from 'react';
import {
Box,
HStack,
VStack,
Text,
Badge,
Card,
CardBody,
Divider,
SimpleGrid,
useColorModeValue,
} from '@chakra-ui/react';
import ConceptStockItem from './ConceptStockItem';
/**
* 详细概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* - avg_change_pct: 平均涨跌幅
* - description: 概念描述
* - happened_times: 历史触发时间数组
* - stocks: 相关股票数组
* @param {Function} props.onClick - 点击回调
*/
const DetailedConceptCard = ({ concept, onClick }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
// 计算相关度百分比
const relevanceScore = Math.round((concept.score || 0) * 100);
// 计算涨跌幅颜色
const changePct = parseFloat(concept.price_info?.avg_change_pct);
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
const changeSymbol = changePct > 0 ? '+' : '';
return (
<Card
bg={cardBg}
borderColor={borderColor}
borderWidth="2px"
cursor="pointer"
transition="all 0.3s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'xl',
borderColor: 'blue.400'
}}
onClick={() => onClick(concept)}
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
{/* 头部信息 */}
<HStack justify="space-between" align="flex-start">
{/* 左侧:概念名称 + Badge */}
<VStack align="start" spacing={2} flex={1}>
<Text fontSize="md" fontWeight="bold" color="blue.600">
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {relevanceScore}%
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
</HStack>
</VStack>
{/* 右侧:涨跌幅 */}
{concept.price_info?.avg_change_pct && (
<Box textAlign="right">
<Text fontSize="xs" color={stockCountColor} mb={1}>
平均涨跌幅
</Text>
<Badge
size="lg"
colorScheme={changeColor}
fontSize="md"
px={3}
py={1}
>
{changeSymbol}{changePct.toFixed(2)}%
</Badge>
</Box>
)}
</HStack>
<Divider />
{/* 概念描述 */}
{concept.description && (
<Text
fontSize="sm"
color={stockCountColor}
lineHeight="1.6"
noOfLines={3}
>
{concept.description}
</Text>
)}
{/* 历史触发时间 */}
{concept.happened_times && concept.happened_times.length > 0 && (
<Box>
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
历史触发时间
</Text>
<HStack spacing={2} flexWrap="wrap">
{concept.happened_times.map((time, idx) => (
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
{time}
</Badge>
))}
</HStack>
</Box>
)}
{/* 核心相关股票 */}
{concept.stocks && concept.stocks.length > 0 && (
<Box>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
核心相关股票
</Text>
<Text fontSize="xs" color={stockCountColor}>
{concept.stock_count}
</Text>
</HStack>
<SimpleGrid columns={{ base: 1 }} spacing={2}>
{concept.stocks.slice(0, 4).map((stock, idx) => (
<ConceptStockItem key={idx} stock={stock} />
))}
</SimpleGrid>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
};
export default DetailedConceptCard;

View File

@@ -0,0 +1,74 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/SimpleConceptCard.js
// 简单概念卡片组件(横向卡片)
import React from 'react';
import {
Flex,
Box,
Text,
useColorModeValue,
} from '@chakra-ui/react';
/**
* 简单概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* @param {Function} props.onClick - 点击回调
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
*/
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
const cardBg = useColorModeValue('white', 'gray.700');
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
const borderColor = useColorModeValue('gray.300', 'gray.600');
const relevanceScore = Math.round((concept.score || 0) * 100);
const relevanceColors = getRelevanceColor(relevanceScore);
return (
<Flex
align="center"
justify="space-between"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
px={4}
py={2}
cursor="pointer"
transition="all 0.2s"
minW="200px"
_hover={{
transform: 'translateY(-1px)',
boxShadow: 'md',
}}
onClick={() => onClick(concept)}
>
{/* 左侧:概念名 + 数量 */}
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
{concept.concept}{' '}
<Text as="span" color="gray.500">
({concept.stock_count})
</Text>
</Text>
{/* 右侧:相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={3}
py={1}
borderRadius="md"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
</Flex>
);
};
export default SimpleConceptCard;

View File

@@ -0,0 +1,46 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/TradingDateInfo.js
// 交易日期信息提示组件
import React from 'react';
import {
Box,
HStack,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment';
/**
* 交易日期信息提示组件
* @param {Object} props
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
*/
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
if (!effectiveTradingDate) {
return null;
}
return (
<Box mb={4} p={3} bg={sectionBg} borderRadius="md">
<HStack spacing={2}>
<FaCalendarAlt color="gray" />
<Text fontSize="sm" color={headingColor}>
涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text>
)}
</Text>
</HStack>
</Box>
);
};
export default TradingDateInfo;

View File

@@ -0,0 +1,256 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
// 相关概念区组件(主组件)
import React, { useState, useEffect } from 'react';
import {
Box,
SimpleGrid,
Flex,
Button,
Collapse,
Heading,
Center,
Spinner,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo';
import { logger } from '../../../../../utils/logger';
/**
* 相关概念区组件
* @param {Object} props
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
*/
const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate();
// 颜色配置
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const textColor = useColorModeValue('gray.600', 'gray.400');
console.log('[RelatedConceptsSection] 组件渲染', {
eventTitle,
effectiveTradingDate,
eventTime,
loading,
conceptsCount: concepts?.length || 0,
error
});
// 搜索相关概念
useEffect(() => {
const searchConcepts = async () => {
console.log('[RelatedConceptsSection] useEffect 触发', {
eventTitle,
effectiveTradingDate
});
if (!eventTitle || !effectiveTradingDate) {
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
hasEventTitle: !!eventTitle,
hasEffectiveTradingDate: !!effectiveTradingDate
});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// 格式化交易日期 - 统一使用 moment 处理
let formattedTradeDate;
try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
// 验证日期是否有效
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = moment().format('YYYY-MM-DD');
}
} catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = moment().format('YYYY-MM-DD');
}
const requestBody = {
query: eventTitle,
size: 5,
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
};
console.log('[RelatedConceptsSection] 发送请求', {
url: '/concept-api/search',
requestBody
});
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
const response = await fetch('/concept-api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
console.log('[RelatedConceptsSection] 响应状态', {
ok: response.ok,
status: response.status,
statusText: response.statusText
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('[RelatedConceptsSection] 响应数据', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0,
hasDataConcepts: !!(data.data && data.data.concepts),
data: data
});
logger.debug('RelatedConceptsSection', '概念搜索响应', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0
});
// 设置概念数据
if (data.results && Array.isArray(data.results)) {
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
setConcepts(data.results);
} else if (data.data && data.data.concepts) {
// 向后兼容
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
setConcepts(data.data.concepts);
} else {
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
setConcepts([]);
}
} catch (err) {
console.error('[RelatedConceptsSection] 搜索概念失败', err);
logger.error('RelatedConceptsSection', 'searchConcepts', err);
setError('加载概念数据失败');
setConcepts([]);
} finally {
console.log('[RelatedConceptsSection] 加载完成');
setLoading(false);
}
};
searchConcepts();
}, [eventTitle, effectiveTradingDate]);
// 加载中状态
if (loading) {
return (
<Box bg={sectionBg} p={3} borderRadius="md">
<Center py={4}>
<Spinner size="md" color="blue.500" mr={2} />
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
</Center>
</Box>
);
}
// 如果没有概念,不渲染
if (!concepts || concepts.length === 0) {
return null;
}
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
* @param {number} relevance - 相关度0-100
* @returns {Object} 包含背景色和文字色
*/
const getRelevanceColor = (relevance) => {
if (relevance >= 90) {
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
} else if (relevance >= 80) {
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
} else if (relevance >= 70) {
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
} else {
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
}
};
/**
* 处理概念点击
* @param {Object} concept - 概念对象
*/
const handleConceptClick = (concept) => {
// 跳转到概念中心,并搜索该概念
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
};
return (
<Box bg={sectionBg} p={3} borderRadius="md">
{/* 标题栏 */}
<Flex justify="space-between" align="center" mb={3}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '查看详细描述'}
</Button>
</Flex>
{/* 简单模式:横向卡片列表(总是显示) */}
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{concepts.map((concept, index) => (
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</Flex>
{/* 交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{/* 详细概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
</Collapse>
</Box>
);
};
export default RelatedConceptsSection;

View File

@@ -0,0 +1,66 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(可折叠,网格布局)
import React from 'react';
import {
Box,
SimpleGrid,
Collapse,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
import StockListItem from './StockListItem';
/**
* 相关股票列表区组件
* @param {Object} props
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间
* @param {Set} props.watchlistSet - 自选股代码集合
* @param {boolean} props.isOpen - 是否展开
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const RelatedStocksSection = ({
stocks,
quotes = {},
eventTime = null,
watchlistSet = new Set(),
isOpen,
onToggle,
onWatchlistToggle
}) => {
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<Box>
<CollapsibleHeader
title="相关股票"
isOpen={isOpen}
onToggle={onToggle}
count={stocks.length}
/>
<Collapse in={isOpen} animateOpacity>
<Box mt={3}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</SimpleGrid>
</Box>
</Collapse>
</Box>
);
};
export default RelatedStocksSection;

View File

@@ -0,0 +1,247 @@
// src/views/Community/components/DynamicNewsDetail/StockListItem.js
// 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react';
import {
Box,
Flex,
VStack,
SimpleGrid,
Text,
Button,
IconButton,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import StockChartModal from '../../../../components/StockChart/StockChartModal';
/**
* 股票卡片组件
* @param {Object} props
* @param {Object} props.stock - 股票对象
* @param {string} props.stock.stock_name - 股票名称
* @param {string} props.stock.stock_code - 股票代码
* @param {string} props.stock.relation_desc - 关联描述
* @param {Object} props.quote - 股票行情数据(可选)
* @param {number} props.quote.change - 涨跌幅
* @param {string} props.eventTime - 事件时间(可选)
* @param {boolean} props.isInWatchlist - 是否在自选股中
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const StockListItem = ({
stock,
quote = null,
eventTime = null,
isInWatchlist = false,
onWatchlistToggle
}) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const codeColor = useColorModeValue('blue.600', 'blue.300');
const nameColor = useColorModeValue('gray.700', 'gray.300');
const descColor = useColorModeValue('gray.600', 'gray.400');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleViewDetail = () => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
};
const handleWatchlistClick = (e) => {
e.stopPropagation();
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
};
// 格式化涨跌幅显示
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 获取涨跌幅数据(优先使用 quotefallback 到 stock
const change = quote?.change ?? stock.daily_change ?? null;
// 处理关联描述
const getRelationDesc = () => {
const relationDesc = stock.relation_desc;
if (!relationDesc) return '--';
if (typeof relationDesc === 'string') {
return relationDesc;
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
return relationDesc.data
.map(item => item.query_part || item.sentences || '')
.filter(s => s)
.join('') || '--';
}
return '--';
};
const relationText = getRelationDesc();
const maxLength = 50; // 收缩时显示的最大字符数
const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength;
return (
<>
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
onClick={handleViewDetail}
cursor="pointer"
_hover={{
boxShadow: 'md',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 操作按钮 */}
<Flex justify="space-between" align="center">
{/* 左侧:代码 + 名称 */}
<Flex align="baseline" gap={2}>
<Text
fontSize="md"
fontWeight="bold"
color={codeColor}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text fontSize="sm" color={nameColor}>
{stock.stock_name}
</Text>
<Text
fontSize="sm"
fontWeight="semibold"
color={getChangeColor(change)}
>
{formatChange(change)}
</Text>
</Flex>
{/* 右侧:操作按钮 */}
<Flex gap={2}>
{onWatchlistToggle && (
<IconButton
size="sm"
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
title={isInWatchlist ? '已关注' : '加自选'}
/>
)}
<Button
size="sm"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
handleViewDetail();
}}
>
查看
</Button>
</Flex>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 分时图 & K线图 - 左右布局 */}
<Box>
<SimpleGrid columns={2} spacing={3}>
{/* 左侧:分时图 */}
<Box onClick={(e) => e.stopPropagation()}>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
分时图
</Text>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
{/* 右侧K线图 */}
<Box onClick={(e) => e.stopPropagation()}>
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
日K线
</Text>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
</SimpleGrid>
</Box>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 关联描述 */}
{relationText && relationText !== '--' && (
<Box>
<Text fontSize="xs" color={descColor} mb={1}>
关联描述
</Text>
<Collapse in={isDescExpanded} startingHeight={40}>
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
{relationText}
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
mt={1}
>
{isDescExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
</VStack>
</Box>
{/* 股票详情弹窗 */}
<StockChartModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
stock={stock}
eventTime={eventTime}
size="6xl"
/>
</>
);
};
export default StockListItem;

View File

@@ -0,0 +1,5 @@
// src/views/Community/components/DynamicNewsDetail/index.js
// 统一导出 DynamicNewsDetailPanel 组件
export { default } from './DynamicNewsDetailPanel';
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';

View File

@@ -0,0 +1,217 @@
// src/views/Community/components/EventCard/DynamicNewsEventCard.js
// 动态新闻事件卡片组件(纵向布局,时间在上)
import React from 'react';
import {
VStack,
Card,
CardBody,
Box,
Text,
HStack,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverArrow,
Portal,
useColorModeValue,
} from '@chakra-ui/react';
import moment from 'moment';
import { getImportanceConfig, getAllImportanceLevels } from '../../../../constants/importanceLevels';
// 导入子组件
import EventFollowButton from './EventFollowButton';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
/**
* 动态新闻事件卡片组件(极简版)
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {number} props.index - 事件索引
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {boolean} props.isSelected - 是否被选中
* @param {Function} props.onEventClick - 卡片点击事件
* @param {Function} props.onTitleClick - 标题点击事件
* @param {Function} props.onToggleFollow - 切换关注事件
* @param {Object} props.timelineStyle - 时间轴样式配置
* @param {string} props.borderColor - 边框颜色
*/
const DynamicNewsEventCard = ({
event,
index,
isFollowing,
followerCount,
isSelected = false,
onEventClick,
onTitleClick,
onToggleFollow,
timelineStyle,
borderColor,
}) => {
const importance = getImportanceConfig(event.importance);
const cardBg = useColorModeValue('white', 'gray.800');
const linkColor = useColorModeValue('blue.600', 'blue.400');
return (
<VStack align="stretch" spacing={2} w="full">
{/* 时间标签 - 在卡片上方 */}
<Box
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
borderWidth={timelineStyle.borderWidth}
borderColor={timelineStyle.borderColor}
borderRadius="md"
px={3}
py={1.5}
textAlign="center"
boxShadow={timelineStyle.boxShadow}
transition="all 0.3s ease"
>
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
</Box>
{/* 事件卡片 */}
<Card
position="relative"
bg={isSelected
? useColorModeValue('blue.50', 'blue.900')
: (index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750'))
}
borderWidth={isSelected ? "2px" : "1px"}
borderColor={isSelected
? useColorModeValue('blue.500', 'blue.400')
: borderColor
}
borderRadius="md"
boxShadow={isSelected ? "lg" : "sm"}
overflow="hidden"
_hover={{
boxShadow: 'xl',
transform: 'translateY(-2px)',
borderColor: isSelected ? 'blue.600' : importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick?.(event)}
>
<CardBody p={3}>
{/* 左上角:重要性矩形角标(镂空边框样式) */}
<Popover trigger="hover" placement="right" isLazy>
<PopoverTrigger>
<Box
position="absolute"
top={0}
left={0}
zIndex={1}
bg="transparent"
color={importance.badgeBg}
borderWidth="2px"
borderColor={importance.badgeBg}
fontSize="11px"
fontWeight="bold"
px={1.5}
py={0.5}
minW="auto"
display="flex"
alignItems="center"
justifyContent="center"
lineHeight="1"
borderBottomRightRadius="md"
cursor="help"
>
{importance.label}
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent width="auto" maxW="350px">
<PopoverArrow />
<PopoverBody p={3}>
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" fontWeight="bold" mb={1}>
重要性等级说明
</Text>
{getAllImportanceLevels().map(item => (
<HStack key={item.level} spacing={2} align="flex-start">
<Box
w="20px"
h="20px"
borderWidth="2px"
borderColor={item.badgeBg}
color={item.badgeBg}
fontSize="9px"
fontWeight="bold"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="sm"
flexShrink={0}
>
{item.level}
</Box>
<Text fontSize="xs" flex={1}>
<Text as="span" fontWeight="bold">
{item.label}
</Text>
{item.description}
</Text>
</HStack>
))}
</VStack>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
{/* 右上角:关注按钮 */}
<Box position="absolute" top={2} right={2} zIndex={1}>
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={() => onToggleFollow?.(event.id)}
size="xs"
showCount={false}
/>
</Box>
<VStack align="stretch" spacing={2}>
{/* 标题 - 最多两行,添加上边距避免与角标重叠 */}
<Box
cursor="pointer"
onClick={(e) => onTitleClick?.(e, event)}
mt={1}
paddingRight="10px"
>
<Text
fontSize="md"
fontWeight="semibold"
color={linkColor}
lineHeight="1.4"
noOfLines={2}
_hover={{ textDecoration: 'underline' }}
>
{event.title}
</Text>
</Box>
{/* 第二行:涨跌幅数据 */}
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
/>
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default DynamicNewsEventCard;

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/EventCard/EventFollowButton.js
import React from 'react';
import { Button } from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import { IconButton, Box } from '@chakra-ui/react';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
/**
* 事件关注按钮组件
@@ -19,7 +19,7 @@ const EventFollowButton = ({
size = 'sm',
showCount = true
}) => {
const iconSize = size === 'xs' ? '10px' : '12px';
const iconSize = size === 'xs' ? '16px' : size === 'sm' ? '18px' : '22px';
const handleClick = (e) => {
e.stopPropagation();
@@ -27,16 +27,38 @@ const EventFollowButton = ({
};
return (
<Button
size={size}
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon boxSize={iconSize} />}
onClick={handleClick}
>
{isFollowing ? '已关注' : '关注'}
{showCount && followerCount > 0 && `(${followerCount})`}
</Button>
<Box display="inline-flex" alignItems="center" gap={1}>
<IconButton
size={size}
colorScheme="yellow"
variant="ghost"
bg="whiteAlpha.500"
boxShadow="sm"
_hover={{
bg: 'whiteAlpha.800',
boxShadow: 'md'
}}
icon={
isFollowing ? (
<AiFillStar
size={iconSize}
color="gold"
/>
) : (
<AiOutlineStar
size={iconSize}
color="#718096"
strokeWidth="1"
/>
)
}
onClick={handleClick}
aria-label={isFollowing ? '取消关注' : '关注'}
/>
{/* <Box fontSize="xs" color="gray.500">
{followerCount || 0}
</Box> */}
</Box>
);
};

View File

@@ -349,12 +349,13 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
<Container maxW="container.xl">
<Flex justify="space-between" align="center">
{/* 左侧占位 */}
<Box flex="1" />
<Box key="left-spacer" flex="1" />
{/* 中间:分页器 */}
{pagination.total > 0 && localEvents.length > 0 ? (
<Flex align="center" gap={2}>
<Flex key="pagination-controls" align="center" gap={2}>
<Button
key="prev-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current - 1)}
@@ -362,10 +363,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
>
上一页
</Button>
<Text fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
{pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)}
</Text>
<Button
key="next-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current + 1)}
@@ -373,18 +375,19 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
>
下一页
</Button>
<Text fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
{pagination.total}
</Text>
</Flex>
) : (
<Box flex="1" />
<Box key="center-spacer" flex="1" />
)}
{/* 右侧:控制按钮 */}
<Flex align="center" gap={3} flex="1" justify="flex-end">
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
{/* WebSocket 连接状态 */}
<Badge
key="websocket-status"
colorScheme={isConnected ? 'green' : 'red'}
fontSize="xs"
px={2}
@@ -395,7 +398,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</Badge>
{/* 桌面推送开关 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
推送
</FormLabel>
@@ -420,7 +423,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</FormControl>
{/* 视图切换控制 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
精简
</FormLabel>
@@ -440,7 +443,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
{/* 事件列表内容 */}
<Container maxW="container.xl">
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
<VStack key="event-list" align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
<EventCard
@@ -460,10 +463,10 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
))}
</VStack>
) : (
<Center h="300px">
<Center key="empty-state" h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
<Text key="empty-text" color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
@@ -472,6 +475,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
{pagination.total > 0 && (
<Pagination
key="bottom-pagination"
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}

View File

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

View File

@@ -15,9 +15,10 @@ import {
*
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @returns {JSX.Element}
*/
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime }) {
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
@@ -162,7 +163,14 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
}, [data, loading, stableEventTime]);
return (
<div style={{ width: 140, height: 40 }}>
<div
style={{
width: 140,
height: 40,
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}
>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
@@ -172,9 +180,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
</div>
);
}, (prevProps, nextProps) => {
// 自定义比较函数只有当stockCodeeventTime变化时才重新渲染
// 自定义比较函数只有当stockCodeeventTime或onClick变化时才重新渲染
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime;
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
});
export default MiniTimelineChart;

View File

@@ -15,11 +15,12 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
* 获取缓存键
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {string} 缓存键
*/
export const getCacheKey = (stockCode, eventTime) => {
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
return `${stockCode}|${date}`;
return `${stockCode}|${date}|${chartType}`;
};
/**
@@ -52,10 +53,11 @@ export const shouldRefreshData = (cacheKey) => {
* 获取K线数据带缓存和防重复请求
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {Promise<Array>} K线数据
*/
export const fetchKlineData = async (stockCode, eventTime) => {
const cacheKey = getCacheKey(stockCode, eventTime);
export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline') => {
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
// 1. 检查缓存
if (klineDataCache.has(cacheKey)) {
@@ -73,10 +75,10 @@ export const fetchKlineData = async (stockCode, eventTime) => {
}
// 3. 发起新请求
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey });
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
const requestPromise = stockService
.getKlineData(stockCode, 'timeline', normalizedEventTime)
.getKlineData(stockCode, chartType, normalizedEventTime)
.then((res) => {
const data = Array.isArray(res?.data) ? res.data : [];
// 更新缓存
@@ -86,12 +88,13 @@ export const fetchKlineData = async (stockCode, eventTime) => {
pendingRequests.delete(cacheKey);
logger.debug('klineDataCache', 'K线数据请求完成并缓存', {
cacheKey,
chartType,
dataPoints: data.length
});
return data;
})
.catch((error) => {
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, cacheKey });
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, chartType, cacheKey });
// 清除pending状态
pendingRequests.delete(cacheKey);
// 如果有旧缓存,返回旧数据

View File

@@ -102,17 +102,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
// 保持现有筛选条件,只更新页码
updateFilters({ ...filters, page });
// 滚动到实时事件时间轴(平滑滚动)
if (eventTimelineRef && eventTimelineRef.current) {
setTimeout(() => {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'start' // 滚动到元素顶部
});
}, 100); // 延迟100ms确保DOM更新
}
}, [filters, updateFilters, eventTimelineRef, track]);
}, [filters, updateFilters, track]);
// 处理事件点击
const handleEventClick = useCallback((event) => {

View File

@@ -1,8 +1,13 @@
// src/views/Community/index.js
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
import {
fetchPopularKeywords,
fetchHotEvents,
fetchDynamicNews,
selectDynamicNewsWithLoading
} from '../../store/slices/communityDataSlice';
import {
Box,
Container,
@@ -11,6 +16,8 @@ import {
// 导入组件
import EventTimelineCard from './components/EventTimelineCard';
import DynamicNewsCard from './components/DynamicNewsCard';
import MarketReviewCard from './components/MarketReviewCard';
import HotEventsSection from './components/HotEventsSection';
import EventModals from './components/EventModals';
@@ -19,6 +26,13 @@ import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters';
import { useCommunityEvents } from './hooks/useCommunityEvents';
// 导入时间工具函数
import {
getCurrentTradingTimeRange,
getMarketReviewTimeRange,
filterEventsByTimeRange
} from '../../utils/tradingTimeUtils';
import { logger } from '../../utils/logger';
import { useNotification } from '../../contexts/NotificationContext';
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
@@ -33,6 +47,13 @@ const Community = () => {
// Redux状态
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
const {
data: allCachedEvents,
loading: dynamicNewsLoading,
error: dynamicNewsError,
total: dynamicNewsTotal,
cachedCount: dynamicNewsCachedCount
} = useSelector(selectDynamicNewsWithLoading);
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
@@ -40,6 +61,7 @@ const Community = () => {
// Ref用于滚动到实时事件时间轴
const eventTimelineRef = useRef(null);
const hasScrolledRef = useRef(false); // 标记是否已滚动
const containerRef = useRef(null); // 用于首次滚动到内容区域
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
@@ -60,12 +82,40 @@ const Community = () => {
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
// 加载热门关键词和热点事件使用Redux内部有缓存判断
// 计算市场复盘的时间范围和过滤后的事件
const marketReviewData = useMemo(() => {
const timeRange = getMarketReviewTimeRange();
const filteredEvents = filterEventsByTimeRange(events, timeRange.startTime, timeRange.endTime);
logger.debug('Community', '市场复盘时间范围', {
description: timeRange.description,
rangeType: timeRange.rangeType,
eventCount: filteredEvents.length
});
return {
events: filteredEvents,
timeRange
};
}, [events]);
// 加载热门关键词和热点事件(动态新闻由 DynamicNewsCard 内部管理)
useEffect(() => {
dispatch(fetchPopularKeywords());
dispatch(fetchHotEvents());
}, [dispatch]);
// 每5分钟刷新一次动态新闻使用 prependMode 追加到头部)
useEffect(() => {
const interval = setInterval(() => {
dispatch(fetchDynamicNews({
page: 1,
per_page: 10, // 获取最新的10条
prependMode: true // 追加到头部,不清空缓存
}));
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [dispatch]);
// 🎯 PostHog 追踪:页面浏览
// useEffect(() => {
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
@@ -86,7 +136,7 @@ const Community = () => {
industryFilter: filters.industry_code,
});
}
}, [events, loading, pagination, filters, communityEvents]);
}, [events, loading, pagination, filters]);
// ⚡ 首次访问社区时,延迟显示权限引导
useEffect(() => {
@@ -100,6 +150,23 @@ const Community = () => {
}
}, [showCommunityGuide]); // 只在组件挂载时执行一次
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
useEffect(() => {
// 延迟执行确保DOM已完全渲染
const timer = setTimeout(() => {
if (containerRef.current) {
// 滚动到容器顶部,自动考虑导航栏的高度
containerRef.current.scrollIntoView({
behavior: 'auto',
block: 'start',
inline: 'nearest'
});
}
}, 0);
return () => clearTimeout(timer);
}, []); // 空依赖数组,只在组件挂载时执行一次
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
const scrollToTimeline = useCallback(() => {
if (!hasScrolledRef.current && eventTimelineRef.current) {
@@ -116,12 +183,39 @@ const Community = () => {
return (
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container maxW="container.xl" pt={6} pb={8}>
<Container ref={containerRef} maxW="container.xl" pt={6} pb={8}>
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />
{/* 实时事件 */}
<EventTimelineCard
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
mt={6}
allCachedEvents={allCachedEvents}
loading={dynamicNewsLoading}
total={dynamicNewsTotal}
cachedCount={dynamicNewsCachedCount}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}
onSearch={updateFilters}
onSearchFocus={scrollToTimeline}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
{/* 市场复盘 - 左右布局 */}
{/* <MarketReviewCard
mt={6}
events={marketReviewData.events}
loading={loading}
lastUpdateTime={lastUpdateTime}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
onToggleFollow={() => {}}
/> */}
{/* 实时事件 - 原纵向列表 */}
{/* <EventTimelineCard
ref={eventTimelineRef}
mt={6}
events={events}
@@ -135,7 +229,7 @@ const Community = () => {
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/>
/> */}
</Container>
{/* 事件弹窗 */}

View File

@@ -406,12 +406,13 @@ const ConceptTimelineModal = ({
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent maxW="1400px" m={4}>
<ModalHeader
@@ -747,7 +748,7 @@ const ConceptTimelineModal = ({
onClick={() => {
if (event.type === 'news') {
// 🎯 追踪新闻点击和详情打开
trackNewsClicked(event, date);
trackNewsClicked(event, item.date);
trackNewsDetailOpened(event);
setSelectedNews({
@@ -760,7 +761,7 @@ const ConceptTimelineModal = ({
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
// 🎯 追踪研报点击和详情打开
trackReportClicked(event, date);
trackReportClicked(event, item.date);
trackReportDetailOpened(event);
setSelectedReport({
@@ -840,14 +841,16 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 研报全文Modal */}
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
{isReportModalOpen && (
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="green.500" color="white">
@@ -919,14 +922,16 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 新闻全文Modal */}
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
{isNewsModalOpen && (
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="blue.500" color="white">
@@ -989,6 +994,7 @@ const ConceptTimelineModal = ({
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};

View File

@@ -283,7 +283,7 @@ export default function CenterDashboard() {
icon={<FiPlus />}
variant="ghost"
size="sm"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
aria-label="添加自选股"
/>
</Flex>
@@ -300,7 +300,7 @@ export default function CenterDashboard() {
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
>
添加自选股
</Button>
@@ -321,7 +321,7 @@ export default function CenterDashboard() {
<VStack align="start" spacing={0}>
<LinkOverlay
as={Link}
to={`/stock-analysis/company?scode=${stock.stock_code}`}
to={`/company/${stock.stock_code}`}
>
<Text fontWeight="medium" fontSize="sm">
{stock.stock_name || stock.stock_code}
@@ -365,7 +365,7 @@ export default function CenterDashboard() {
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/stock-analysis/overview')}
onClick={() => navigate('/stocks')}
>
查看全部 ({watchlist.length})
</Button>

View File

@@ -1,5 +1,6 @@
// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
@@ -7,92 +8,85 @@ import {
Text,
Badge,
Button,
Collapse,
useDisclosure,
Skeleton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Divider,
SimpleGrid,
Icon,
useColorModeValue,
Tooltip,
Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Spinner,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Link
Link,
Flex,
Collapse
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
FaClock,
FaCalendarAlt,
FaChartLine,
FaEye,
FaTimes,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
// 不要在 if/循环/回调中调用 Hook
const [expandedEvents, setExpandedEvents] = useState(new Set());
const [selectedEvent, setSelectedEvent] = useState(null);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState(false);
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
const navigate = useNavigate();
const { isOpen, onOpen, onClose } = useDisclosure();
// 状态管理
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
const [stocksModalOpen, setStocksModalOpen] = useState(false);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState({});
// 颜色主题
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 切换事件展开状态
const toggleEventExpansion = (eventId) => {
const newExpanded = new Set(expandedEvents);
if (newExpanded.has(eventId)) {
newExpanded.delete(eventId);
} else {
newExpanded.add(eventId);
}
setExpandedEvents(newExpanded);
// 字段兼容函数
const getEventDate = (event) => {
return event?.event_date || event?.created_at || event?.date || event?.publish_time;
};
// 显示事件相关股票
const showEventStocks = async (event) => {
setSelectedEvent(event);
setLoadingStocks(true);
onOpen();
const getEventContent = (event) => {
return event?.content || event?.description || event?.summary;
};
// Debug: 打印实际数据结构
useEffect(() => {
if (events && events.length > 0) {
console.log('===== Historical Events Debug =====');
console.log('First Event Data:', events[0]);
console.log('Available Fields:', Object.keys(events[0]));
console.log('Date Field:', getEventDate(events[0]));
console.log('Content Field:', getEventContent(events[0]));
console.log('==================================');
}
}, [events]);
// 点击相关股票按钮
const handleViewStocks = async (event) => {
setSelectedEventForStocks(event);
setStocksModalOpen(true);
// 如果已经加载过该事件的股票数据,不再重复加载
if (eventStocks[event.id]) {
return;
}
// 标记为加载中
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
try {
// 如果已经加载过该事件的股票数据,直接使用缓存
if (eventStocks[event.id]) {
setLoadingStocks(false);
return;
}
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
setEventStocks(prev => ({
@@ -100,7 +94,7 @@ const HistoricalEvents = ({
[event.id]: response.data || []
}));
} catch (err) {
logger.error('HistoricalEvents', 'showEventStocks', err, {
logger.error('HistoricalEvents', 'handleViewStocks', err, {
eventId: event.id,
eventTitle: event.title
});
@@ -109,15 +103,19 @@ const HistoricalEvents = ({
[event.id]: []
}));
} finally {
setLoadingStocks(false);
setLoadingStocks(prev => ({ ...prev, [event.id]: false }));
}
};
// 获取重要性图标
const getImportanceIcon = (importance) => {
if (importance >= 4) return FaExclamationTriangle;
if (importance >= 2) return FaCalendarAlt;
return FaClock;
// 关闭弹窗
const handleCloseModal = () => {
setStocksModalOpen(false);
setSelectedEventForStocks(null);
};
// 处理卡片点击跳转到事件详情页
const handleCardClick = (event) => {
navigate(`/event-detail/${event.id}`);
};
// 获取重要性颜色
@@ -149,89 +147,28 @@ const HistoricalEvents = ({
return `${Math.floor(diffDays / 365)}年前`;
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
// 可展开的文本组件
const ExpandableText = ({ text, maxLength = 20 }) => {
const { isOpen, onToggle } = useDisclosure();
const [shouldTruncate, setShouldTruncate] = useState(false);
useEffect(() => {
if (text && text.length > maxLength) {
setShouldTruncate(true);
} else {
setShouldTruncate(false);
}
}, [text, maxLength]);
if (!text) return <Text fontSize="xs">--</Text>;
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
: text;
return (
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
{displayText}{text.includes('AI合成') ? '' : 'AI合成'}
</Text>
{shouldTruncate && (
<Button
size="xs"
variant="link"
color="blue.500"
onClick={onToggle}
height="auto"
py={0}
minH={0}
>
{isOpen ? '收起' : '展开'}
</Button>
)}
</VStack>
);
};
// 加载状态
if (loading) {
return (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{[1, 2, 3].map((i) => (
<Card key={i} borderLeft="4px solid" borderLeftColor="gray.200">
<CardBody>
<HStack spacing={4} align="flex-start">
<Skeleton boxSize="40px" borderRadius="full" />
<VStack align="flex-start" spacing={2} flex="1">
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="14px" width="90%" />
</VStack>
</HStack>
</CardBody>
</Card>
<Box
key={i}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
>
<VStack align="flex-start" spacing={3}>
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="50%" />
<Skeleton height="60px" width="100%" />
<Skeleton height="32px" width="100px" />
</VStack>
</Box>
))}
</VStack>
</SimpleGrid>
);
}
@@ -263,216 +200,163 @@ const HistoricalEvents = ({
return (
<>
<VStack spacing={4} align="stretch">
{/* 超预期得分显示 */}
{expectationScore && (
<Card bg={useColorModeValue('yellow.50', 'yellow.900')} borderColor="yellow.200">
<CardBody>
<HStack spacing={3}>
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
<VStack align="flex-start" spacing={1}>
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* 历史事件时间轴 */}
<Box position="relative">
{/* 时间轴线 */}
<Box
position="absolute"
left="20px"
top="20px"
bottom="20px"
width="2px"
background={`linear-gradient(to bottom, ${timelineBg}, #996515)`}
zIndex={0}
/>
{/* 事件列表 */}
<VStack spacing={6} align="stretch">
{events.map((event, index) => {
const ImportanceIcon = getImportanceIcon(event.importance);
const importanceColor = getImportanceColor(event.importance);
const isExpanded = expandedEvents.has(event.id);
return (
<Box key={event.id} position="relative">
{/* 时间轴节点 */}
<Box
position="absolute"
left="0"
top="20px"
width="40px"
height="40px"
borderRadius="full"
bg={cardBg}
border="2px solid"
borderColor={timelineBg}
display="flex"
alignItems="center"
justifyContent="center"
zIndex={1}
>
<Icon
as={ImportanceIcon}
color={`${importanceColor}.500`}
boxSize="16px"
/>
</Box>
{/* 事件内容卡片 */}
<Box ml="60px">
<Card
borderLeft="3px solid"
borderLeftColor={timelineBg}
bg={cardBg}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="flex-start" spacing={3}>
{/* 事件标题和操作 */}
<HStack justify="space-between" align="flex-start" w="100%">
<VStack align="flex-start" spacing={1} flex="1">
<Button
variant="link"
color={useColorModeValue('blue.600', 'blue.400')}
fontWeight="bold"
fontSize="md"
p={0}
h="auto"
onClick={() => toggleEventExpansion(event.id)}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Button>
<HStack spacing={3} fontSize="sm" color={textSecondary}>
<Text>{formatDate(event.event_date)}</Text>
<Text>({getRelativeTime(event.event_date)})</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
</HStack>
</VStack>
<HStack spacing={2}>
{event.importance && (
<Tooltip label={`重要性等级: ${event.importance}/5`}>
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
</Tooltip>
)}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={() => showEventStocks(event)}
colorScheme="blue"
variant="outline"
>
相关股票
</Button>
</HStack>
</HStack>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content ? `${event.content}AI合成` : '暂无内容'}
</Text>
{/* 展开的详细信息 */}
<Collapse in={isExpanded} animateOpacity>
<Box pt={3} borderTop="1px solid" borderTopColor={borderColor}>
<VStack align="flex-start" spacing={2}>
<Text fontSize="xs" color={textSecondary}>
事件ID: {event.id}
</Text>
{event.source && (
<Text fontSize="xs" color={textSecondary}>
来源: {event.source}
</Text>
)}
{event.tags && event.tags.length > 0 && (
<HStack spacing={1} flexWrap="wrap">
<Text fontSize="xs" color={textSecondary}>标签:</Text>
{event.tags.map((tag, idx) => (
<Badge key={idx} size="sm" variant="outline">
{tag}
</Badge>
))}
</HStack>
)}
</VStack>
</Box>
</Collapse>
</VStack>
</CardBody>
</Card>
</Box>
</Box>
);
})}
</VStack>
</Box>
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalHeader>
{/* 超预期得分显示 */}
{expectationScore && (
<Box
mb={4}
p={3}
bg={useColorModeValue('yellow.50', 'yellow.900')}
borderColor="yellow.200"
borderWidth="1px"
borderRadius="md"
>
<HStack spacing={3}>
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
<Text fontSize="sm" color={textSecondary} fontWeight="normal">
相关股票信息
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton />
</HStack>
</Box>
)}
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
{/* 历史事件卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{events.map((event) => {
const importanceColor = getImportanceColor(event.importance);
return (
<Box
key={event.id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
cursor="pointer"
onClick={() => handleCardClick(event)}
_hover={{
boxShadow: 'lg',
borderColor: 'blue.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 事件名称 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
noOfLines={2}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Text>
{/* 日期 + Badges */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
</HStack>
{/* 事件描述 */}
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={4}
>
{getEventContent(event) ? `${getEventContent(event)}AI合成` : '暂无内容'}
</Text>
{/* 相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
width="full"
>
相关股票
</Button>
</VStack>
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
</Box>
);
})}
</SimpleGrid>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 相关股票 Modal - 条件渲染 */}
{stocksModalOpen && (
<Modal
isOpen={stocksModalOpen}
onClose={handleCloseModal}
size="6xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedEventForStocks?.title || '历史事件相关股票'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{loadingStocks[selectedEventForStocks?.id] ? (
<VStack spacing={4} py={12}>
<Spinner size="xl" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={eventStocks[selectedEventForStocks?.id] || []}
eventTradingDate={getEventDate(selectedEventForStocks)}
/>
)}
</ModalBody>
</ModalContent>
</Modal>
)}
</>
);
};
// 股票列表子组件
// 股票列表子组件(卡片式布局)
const StocksList = ({ stocks, eventTradingDate }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const [expandedStocks, setExpandedStocks] = useState(new Set());
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
@@ -497,9 +381,41 @@ const StocksList = ({ stocks, eventTradingDate }) => {
return '';
};
// 切换展开状态
const toggleExpand = (stockId) => {
const newExpanded = new Set(expandedStocks);
if (newExpanded.has(stockId)) {
newExpanded.delete(stockId);
} else {
newExpanded.add(stockId);
}
setExpandedStocks(newExpanded);
};
// 格式化涨跌幅
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 获取相关度颜色
const getCorrelationColor = (correlation) => {
if (correlation >= 0.8) return 'red';
if (correlation >= 0.6) return 'orange';
return 'green';
};
if (!stocks || stocks.length === 0) {
return (
<Box textAlign="center" py={8} color={textSecondary}>
<Box textAlign="center" py={12} color={textSecondary}>
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
@@ -509,6 +425,7 @@ const StocksList = ({ stocks, eventTradingDate }) => {
return (
<>
{/* 事件交易日提示 */}
{eventTradingDate && (
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
@@ -516,74 +433,115 @@ const StocksList = ({ stocks, eventTradingDate }) => {
</Text>
</Box>
)}
<TableContainer>
<Table size="md">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th isNumeric>事件日涨幅</Th>
<Th>关联原因</Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
isExternal
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
</Link>
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</Td>
<Td isNumeric>
<Badge
colorScheme={
stock.correlation >= 0.8 ? 'red' :
stock.correlation >= 0.6 ? 'orange' : 'green'
}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</Td>
<Td isNumeric>
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
<Text
fontWeight="medium"
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
>
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
</Text>
) : (
<Text color={textSecondary} fontSize="sm">--</Text>
)}
</Td>
<Td>
{/* 股票卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => {
const stockId = stock.id || index;
const isExpanded = expandedStocks.has(stockId);
const cleanCode = stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : '';
const relationDesc = getRelationDesc(stock.relation_desc);
const needTruncate = relationDesc && relationDesc.length > 50;
return (
<Box
key={stockId}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
_hover={{
boxShadow: 'md',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 涨跌幅 */}
<Flex justify="space-between" align="center">
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : '--'}
<Link
href={`https://valuefrontier.cn/company?scode=${cleanCode}`}
isExternal
fontSize="md"
fontWeight="bold"
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{cleanCode}
</Link>
<Text fontSize="sm" color={nameColor}>
{stock.stock_name || '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(stock.event_day_change_pct)}
>
{formatChange(stock.event_day_change_pct)}
</Text>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 板块和相关度 */}
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
<HStack spacing={2}>
<Text fontSize="xs" color={textSecondary}>板块</Text>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="xs" color={textSecondary}>相关度</Text>
<Badge
colorScheme={getCorrelationColor(stock.correlation || 0)}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</HStack>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 关联原因 */}
{relationDesc && (
<Box>
<Text fontSize="xs" color={textSecondary} mb={1}>
关联原因
</Text>
<Collapse in={isExpanded} startingHeight={40}>
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
{relationDesc}AI合成
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => toggleExpand(stockId)}
mt={1}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
</>
);
};
export default HistoricalEvents;
export default HistoricalEvents;

View File

@@ -34,9 +34,6 @@ import moment from 'moment';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
import { logger } from '../../../utils/logger';
// API配置
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
// 增强版 ConceptCard 组件 - 展示更多数据细节
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
const [isExpanded, setIsExpanded] = useState(false);
@@ -331,7 +328,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
logger.debug('RelatedConcepts', '搜索概念', requestBody);
const response = await fetch(`${API_BASE_URL}/search`, {
const response = await fetch('/concept-api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -268,7 +268,7 @@ const RelatedStocks = ({
return (
<VStack spacing={4}>
{[1, 2, 3].map((i) => (
<HStack key={i} w="100%" spacing={4}>
<HStack key={`skeleton-${i}`} w="100%" spacing={4}>
<Skeleton height="20px" width="100px" />
<Skeleton height="20px" width="150px" />
<Skeleton height="20px" width="80px" />

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import {
Box,
@@ -358,6 +358,9 @@ const EventDetail = () => {
const { user } = useAuth();
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
// 滚动位置管理
const scrollPositionRef = useRef(0);
// State hooks
const [eventData, setEventData] = useState(null);
const [relatedStocks, setRelatedStocks] = useState([]);
@@ -399,6 +402,16 @@ const EventDetail = () => {
const actualEventId = getEventIdFromPath();
// 保存当前滚动位置
const saveScrollPosition = () => {
scrollPositionRef.current = window.scrollY || window.pageYOffset;
};
// 恢复滚动位置
const restoreScrollPosition = () => {
window.scrollTo(0, scrollPositionRef.current);
};
const loadEventData = async () => {
try {
setLoading(true);
@@ -540,8 +553,19 @@ const EventDetail = () => {
// Effect hook - must be called after all state hooks
useEffect(() => {
if (actualEventId) {
// 保存当前滚动位置
saveScrollPosition();
loadEventData();
loadPosts();
// 数据加载完成后恢复滚动位置
// 使用 setTimeout 确保 DOM 已更新
const timer = setTimeout(() => {
restoreScrollPosition();
}, 100);
return () => clearTimeout(timer);
} else {
setError('无效的事件ID');
setLoading(false);

View File

@@ -1,629 +0,0 @@
// src/views/Home/HomePage.js
import React, { useState, useEffect } from 'react';
import {
Box,
Flex,
Text,
Button,
Container,
VStack,
HStack,
Icon,
Heading,
useBreakpointValue,
Link,
SimpleGrid,
Divider
} from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; // 添加这个导入来调试
import heroBg from 'assets/img/BackgroundCard1.png';
import teamWorkingImg from 'assets/img/background-card-reports.png';
export default function HomePage() {
const navigate = useNavigate();
const { user, isAuthenticated, isLoading } = useAuth(); // 添加这行来调试
// 添加调试信息
useEffect(() => {
console.log('🏠 HomePage AuthContext 状态:', {
user,
isAuthenticated,
isLoading,
hasUser: !!user,
userInfo: user ? {
id: user.id,
username: user.username,
nickname: user.nickname
} : null
});
}, [user?.id, isAuthenticated, isLoading]); // 只依赖 user.id,避免无限循环
// 统计数据动画
const [stats, setStats] = useState({
dataSize: 0,
dataSources: 0,
researchTargets: 0
});
useEffect(() => {
const targetStats = {
dataSize: 17,
dataSources: 300,
researchTargets: 45646
};
// 动画效果
const animateStats = () => {
const duration = 2000; // 2秒动画
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
setStats({
dataSize: Math.floor(targetStats.dataSize * progress),
dataSources: Math.floor(targetStats.dataSources * progress),
researchTargets: Math.floor(targetStats.researchTargets * progress)
});
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
};
const timer = setTimeout(animateStats, 500);
return () => clearTimeout(timer);
}, []);
return (
<Box>
{/* 临时调试信息栏 - 完成调试后可以删除 */}
{process.env.NODE_ENV === 'development' && (
<Box bg="yellow.100" p={2} fontSize="sm" borderBottom="1px solid" borderColor="yellow.300">
<Text fontWeight="bold">🐛 调试信息:</Text>
<Text>认证状态: {isAuthenticated ? '✅ 已登录' : '❌ 未登录'}</Text>
<Text>加载状态: {isLoading ? '⏳ 加载中' : '✅ 加载完成'}</Text>
<Text>用户信息: {user ? `👤 ${user.nickname || user.username} (ID: ${user.id})` : '❌ 无用户信息'}</Text>
<Text>localStorage: {localStorage.getItem('user') ? '✅ 有数据' : '❌ 无数据'}</Text>
</Box>
)}
{/* Hero Section - Brainwave风格 */}
<Box
position="relative"
minH="100vh"
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
display="flex"
alignItems="center"
justifyContent="center"
overflow="hidden"
>
{/* 同心圆背景装饰 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="78rem"
h="78rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.3}
zIndex={0}
>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="65rem"
h="65rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.2}
/>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="51rem"
h="51rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
/>
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="36rem"
h="36rem"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
/>
</Box>
{/* 动态装饰点 */}
<Box
position="absolute"
top="30%"
right="20%"
w="4"
h="4"
bg="linear-gradient(135deg, #AC6AFF, #1A1A32)"
borderRadius="50%"
animation="float 3s ease-in-out infinite"
zIndex={1}
/>
<Box
position="absolute"
bottom="40%"
left="15%"
w="6"
h="6"
bg="linear-gradient(135deg, #7ADB78, #1A1A32)"
borderRadius="50%"
animation="float 4s ease-in-out infinite 0.5s"
zIndex={1}
/>
<Box
position="absolute"
top="20%"
left="25%"
w="3"
h="3"
bg="linear-gradient(135deg, #FFC876, #1A1A32)"
borderRadius="50%"
animation="float 2.5s ease-in-out infinite 1s"
zIndex={1}
/>
{/* 主要内容 */}
<Container maxW="container.lg" position="relative" zIndex={2}>
<VStack spacing={8} textAlign="center" color="brainwave.n1">
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl", lg: "6xl" }}
fontWeight="bold"
lineHeight={1.1}
letterSpacing="tight"
>
探索
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
人工智能
</Text>
的无限可能
<br />
<Text as="span" fontSize={{ base: "2xl", md: "4xl", lg: "5xl" }} color="brainwave.n2">
价值前沿 AI 投研助手
</Text>
</Heading>
<Text
fontSize={{ base: "lg", md: "xl" }}
color="brainwave.n2"
maxW="600px"
lineHeight={1.6}
>
释放AI的力量升级您的投研效率
体验超越ChatGPT的专业投资分析平台
</Text>
<Button
size="lg"
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
color="white"
_hover={{
transform: "translateY(-2px)",
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
}}
_active={{ transform: "translateY(0)" }}
transition="all 0.2s"
borderRadius="full"
px={8}
py={6}
fontSize="md"
fontWeight="semibold"
onClick={() => navigate('/community')}
>
开始体验
</Button>
</VStack>
</Container>
{/* 渐变底部 */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
height="100px"
zIndex={2}
bg="linear-gradient(to top, white 0%, transparent 100%)"
/>
{/* CSS动画定义 */}
<style jsx>{`
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
`}</style>
</Box>
{/* 统计数据区域 - 玻璃拟态效果 */}
<Box py={12} position="relative" mt={-20} zIndex={3}>
<Container maxW="container.lg">
<Box
bg="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(20px)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.2)"
boxShadow="0 25px 50px rgba(0, 0, 0, 0.25)"
p={8}
position="relative"
_before={{
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: "xl",
background: "linear-gradient(135deg, rgba(172, 106, 255, 0.1) 0%, rgba(255, 200, 118, 0.1) 100%)",
zIndex: -1
}}
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8}>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.purple, brainwave.orange)"
bgClip="text"
fontWeight="bold"
>
{stats.dataSize}TB
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">基础数据</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们收集来自全世界的各类数据打造您的专属智能投资助手
</Text>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.green, brainwave.blue)"
bgClip="text"
fontWeight="bold"
>
{stats.dataSources}+
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">数据源</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们即时采集来自300多家数据源的实时数据随时满足您的投研需求
</Text>
</VStack>
<VStack textAlign="center" spacing={4}>
<Heading
size="2xl"
bgGradient="linear(to-r, brainwave.pink, brainwave.red)"
bgClip="text"
fontWeight="bold"
>
{stats.researchTargets.toLocaleString()}
</Heading>
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">研究标的</Heading>
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
我们的研究范围涵盖全球主流市场包括股票外汇大宗等交易类型给您足够宏观的视角
</Text>
</VStack>
</SimpleGrid>
</Box>
</Container>
</Box>
{/* 特色功能介绍 - Brainwave深色风格 */}
<Box as="section" py={20} bg="brainwave.n8" position="relative" overflow="hidden">
{/* 背景装饰几何图形 */}
<Box
position="absolute"
top="10%"
right="-5%"
w="300px"
h="300px"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
zIndex={0}
/>
<Box
position="absolute"
bottom="10%"
left="-5%"
w="200px"
h="200px"
border="1px solid"
borderColor="brainwave.n6"
borderRadius="50%"
opacity={0.1}
zIndex={0}
/>
<Container maxW="container.xl" position="relative" zIndex={1}>
<Flex align="center" gap={16}>
{/* 左侧功能介绍 - 深色主题版本 */}
<Box flex="1" ml="auto">
{/* 第一行 */}
<SimpleGrid columns={2} spacing={8} mb={12}>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 40 44" w="25px" h="25px" color="brainwave.purple">
<path fill="currentColor" d="M40,40 L36.3636364,40 L36.3636364,3.63636364 L5.45454545,3.63636364 L5.45454545,0 L38.1818182,0 C39.1854545,0 40,0.814545455 40,1.81818182 L40,40 Z" opacity="0.603585379"/>
<path fill="currentColor" d="M30.9090909,7.27272727 L1.81818182,7.27272727 C0.814545455,7.27272727 0,8.08727273 0,9.09090909 L0,41.8181818 C0,42.8218182 0.814545455,43.6363636 1.81818182,43.6363636 L30.9090909,43.6363636 C31.9127273,43.6363636 32.7272727,42.8218182 32.7272727,41.8181818 L32.7272727,9.09090909 C32.7272727,8.08727273 31.9127273,7.27272727 30.9090909,7.27272727 Z M18.1818182,34.5454545 L7.27272727,34.5454545 L7.27272727,30.9090909 L18.1818182,30.9090909 L18.1818182,34.5454545 Z M25.4545455,27.2727273 L7.27272727,27.2727273 L7.27272727,23.6363636 L25.4545455,23.6363636 L25.4545455,27.2727273 Z M25.4545455,20 L7.27272727,20 L7.27272727,16.3636364 L25.4545455,16.3636364 L25.4545455,20 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">人工智能驱动</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
收集海量投研资料和数据确保信息全面丰富<br/>
训练专注于投研的大语言模型专业度领先<br/>
在金融投资领域表现卓越优于市面其他模型
</Text>
</VStack>
</Box>
<Box>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 45 40" w="25px" h="25px" color="brainwave.orange">
<path fill="currentColor" d="M46.7199583,10.7414583 L40.8449583,0.949791667 C40.4909749,0.360605034 39.8540131,0 39.1666667,0 L7.83333333,0 C7.1459869,0 6.50902508,0.360605034 6.15504167,0.949791667 L0.280041667,10.7414583 C0.0969176761,11.0460037 -1.23209662e-05,11.3946378 -1.23209662e-05,11.75 C-0.00758042603,16.0663731 3.48367543,19.5725301 7.80004167,19.5833333 L7.81570833,19.5833333 C9.75003686,19.5882688 11.6168794,18.8726691 13.0522917,17.5760417 C16.0171492,20.2556967 20.5292675,20.2556967 23.494125,17.5760417 C26.4604562,20.2616016 30.9794188,20.2616016 33.94575,17.5760417 C36.2421905,19.6477597 39.5441143,20.1708521 42.3684437,18.9103691 C45.1927731,17.649886 47.0084685,14.8428276 47.0000295,11.75 C47.0000295,11.3946378 46.9030823,11.0460037 46.7199583,10.7414583 Z" opacity="0.598981585"/>
<path fill="currentColor" d="M39.198,22.4912623 C37.3776246,22.4928106 35.5817531,22.0149171 33.951625,21.0951667 L33.92225,21.1107282 C31.1430221,22.6838032 27.9255001,22.9318916 24.9844167,21.7998837 C24.4750389,21.605469 23.9777983,21.3722567 23.4960833,21.1018359 L23.4745417,21.1129513 C20.6961809,22.6871153 17.4786145,22.9344611 14.5386667,21.7998837 C14.029926,21.6054643 13.533337,21.3722507 13.0522917,21.1018359 C11.4250962,22.0190609 9.63246555,22.4947009 7.81570833,22.4912623 C7.16510551,22.4842162 6.51607673,22.4173045 5.875,22.2911849 L5.875,44.7220845 C5.875,45.9498589 6.7517757,46.9451667 7.83333333,46.9451667 L19.5833333,46.9451667 L19.5833333,33.6066734 L27.4166667,33.6066734 L27.4166667,46.9451667 L39.1666667,46.9451667 C40.2482243,46.9451667 41.125,45.9498589 41.125,44.7220845 L41.125,22.2822926 C40.4887822,22.4116582 39.8442868,22.4815492 39.198,22.4912623 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研数据湖</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
AI Agent 24/7 全天候采集全球数据确保实时更新<br/>
整合多种数据源覆盖范围广泛信息丰富<br/>
构建独特数据湖提供业内无可比拟的数据深度
</Text>
</VStack>
</Box>
</SimpleGrid>
{/* 第二行 */}
<SimpleGrid columns={2} spacing={8}>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 44" w="25px" h="25px" color="brainwave.green">
<path fill="currentColor" d="M18.8086957,4.70034783 C15.3814926,0.343541521 9.0713063,-0.410050841 4.7145,3.01715217 C0.357693695,6.44435519 -0.395898667,12.7545415 3.03130435,17.1113478 C5.53738466,10.3360568 11.6337901,5.54042955 18.8086957,4.70034783 L18.8086957,4.70034783 Z" opacity="0.6"/>
<path fill="currentColor" d="M38.9686957,17.1113478 C42.3958987,12.7545415 41.6423063,6.44435519 37.2855,3.01715217 C32.9286937,-0.410050841 26.6185074,0.343541521 23.1913043,4.70034783 C30.3662099,5.54042955 36.4626153,10.3360568 38.9686957,17.1113478 Z" opacity="0.6"/>
<path fill="currentColor" d="M34.3815652,34.7668696 C40.2057958,27.7073059 39.5440671,17.3375603 32.869743,11.0755718 C26.1954189,4.81358341 15.8045811,4.81358341 9.13025701,11.0755718 C2.45593289,17.3375603 1.79420418,27.7073059 7.61843478,34.7668696 L3.9753913,40.0506522 C3.58549114,40.5871271 3.51710058,41.2928217 3.79673036,41.8941824 C4.07636014,42.4955431 4.66004722,42.8980248 5.32153275,42.9456105 C5.98301828,42.9931963 6.61830436,42.6784048 6.98113043,42.1232609 L10.2744783,37.3434783 C16.5555112,42.3298213 25.4444888,42.3298213 31.7255217,37.3434783 L35.0188696,42.1196087 C35.6014207,42.9211577 36.7169135,43.1118605 37.53266,42.5493622 C38.3484064,41.9868639 38.5667083,40.8764423 38.0246087,40.047 L34.3815652,34.7668696 Z M30.1304348,25.5652174 L21,25.5652174 C20.49574,25.5652174 20.0869565,25.1564339 20.0869565,24.6521739 L20.0869565,15.5217391 C20.0869565,15.0174791 20.49574,14.6086957 21,14.6086957 C21.50426,14.6086957 21.9130435,15.0174791 21.9130435,15.5217391 L21.9130435,23.7391304 L30.1304348,23.7391304 C30.6346948,23.7391304 31.0434783,24.1479139 31.0434783,24.6521739 C31.0434783,25.1564339 30.6346948,25.5652174 30.1304348,25.5652174 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研Agent</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
采用 AI 模拟人类分析师智能化程度高<br/>
具备独特的全球视角全面审视各类资产<br/>
提供最佳投资建议支持科学决策
</Text>
</VStack>
</Box>
<Box mt={3}>
<VStack align="start" spacing={4}>
<Box className="icon icon-sm">
<Icon viewBox="0 0 42 42" w="25px" h="25px" color="brainwave.blue">
<path fill="currentColor" d="M12.25,17.5 L8.75,17.5 L8.75,1.75 C8.75,0.78225 9.53225,0 10.5,0 L31.5,0 C32.46775,0 33.25,0.78225 33.25,1.75 L33.25,12.25 L29.75,12.25 L29.75,3.5 L12.25,3.5 L12.25,17.5 Z" opacity="0.6"/>
<path fill="currentColor" d="M40.25,14 L24.5,14 C23.53225,14 22.75,14.78225 22.75,15.75 L22.75,38.5 L19.25,38.5 L19.25,22.75 C19.25,21.78225 18.46775,21 17.5,21 L1.75,21 C0.78225,21 0,21.78225 0,22.75 L0,40.25 C0,41.21775 0.78225,42 1.75,42 L40.25,42 C41.21775,42 42,41.21775 42,40.25 L42,15.75 C42,14.78225 41.21775,14 40.25,14 Z M12.25,36.75 L7,36.75 L7,33.25 L12.25,33.25 L12.25,36.75 Z M12.25,29.75 L7,29.75 L7,26.25 L12.25,26.25 L12.25,29.75 Z M35,36.75 L29.75,36.75 L29.75,33.25 L35,33.25 L35,36.75 Z M35,29.75 L29.75,29.75 L29.75,26.25 L35,26.25 L35,29.75 Z M35,22.75 L29.75,22.75 L29.75,19.25 L35,19.25 L35,22.75 Z"/>
</Icon>
</Box>
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">新闻事件驱动</Heading>
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
基于AI的信息挖掘技术<br/>
Agent 赋能的未来推演和数据关联<br/>
自由交流我们相信集体的力量
</Text>
</VStack>
</Box>
{/* 深研系统 → 盈利预测报表 入口 */}
<Box mt={3}>
<VStack align="start" spacing={3}>
<Heading size="md" fontWeight="bold" mt={1} color="brainwave.n1">深研系统</Heading>
<Button
size="sm"
colorScheme="purple"
variant="ghost"
onClick={() => navigate('/admin/stock-analysis/forecast-report')}
>
盈利预测报表
</Button>
</VStack>
</Box>
</SimpleGrid>
</Box>
{/* 右侧卡片 - 完全按照原网站设计 */}
<Box flex="0 0 auto" w="400px" p={4}>
<Box
position="relative"
borderRadius="xl"
overflow="hidden"
transform="perspective(1000px) rotateY(-5deg)"
boxShadow="2xl"
bgImage={`url(${teamWorkingImg})`}
bgSize="cover"
bgPosition="center"
h="400px"
>
{/* 黑色遮罩 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
/>
{/* 内容 */}
<Flex
direction="column"
align="center"
justify="center"
h="full"
position="relative"
zIndex={1}
color="white"
textAlign="center"
pt={7}
>
{/* 3D盒子图标 */}
<Box className="icon icon-lg up" mb={3} mt={3}>
<Icon viewBox="0 0 42 42" w="50px" h="50px" color="white">
<path fill="currentColor" d="M22.7597136,19.3090182 L38.8987031,11.2395234 C39.3926816,10.9925342 39.592906,10.3918611 39.3459167,9.89788265 C39.249157,9.70436312 39.0922432,9.5474453 38.8987261,9.45068056 L20.2741875,0.1378125 L20.2741875,0.1378125 C19.905375,-0.04725 19.469625,-0.04725 19.0995,0.1378125 L3.1011696,8.13815822 C2.60720568,8.38517662 2.40701679,8.98586148 2.6540352,9.4798254 C2.75080129,9.67332903 2.90771305,9.83023153 3.10122239,9.9269862 L21.8652864,19.3090182 C22.1468139,19.4497819 22.4781861,19.4497819 22.7597136,19.3090182 Z"/>
<path fill="currentColor" d="M23.625,22.429159 L23.625,39.8805372 C23.625,40.4328219 24.0727153,40.8805372 24.625,40.8805372 C24.7802551,40.8805372 24.9333778,40.8443874 25.0722402,40.7749511 L41.2741875,32.673375 L41.2741875,32.673375 C41.719125,32.4515625 42,31.9974375 42,31.5 L42,14.241659 C42,13.6893742 41.5522847,13.241659 41,13.241659 C40.8447549,13.241659 40.6916418,13.2778041 40.5527864,13.3472318 L24.1777864,21.5347318 C23.8390024,21.7041238 23.625,22.0503869 23.625,22.429159 Z" opacity="0.7"/>
<path fill="currentColor" d="M20.4472136,21.5347318 L1.4472136,12.0347318 C0.953235098,11.7877425 0.352562058,11.9879669 0.105572809,12.4819454 C0.0361450918,12.6208008 6.47121774e-16,12.7739139 0,12.929159 L0,30.1875 L0,30.1875 C0,30.6849375 0.280875,31.1390625 0.7258125,31.3621875 L19.5528096,40.7750766 C20.0467945,41.0220531 20.6474623,40.8218132 20.8944388,40.3278283 C20.963859,40.1889789 21,40.0358742 21,39.8806379 L21,22.429159 C21,22.0503869 20.7859976,21.7041238 20.4472136,21.5347318 Z" opacity="0.7"/>
</Icon>
</Box>
<Heading size="xl" color="brainwave.n1" className="up" mb={0} lineHeight="1.2">
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
事件催化
</Text><br />
让成功有迹可循
</Heading>
<Button
onClick={() => navigate('/community')}
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
color="white"
size="lg"
mt={3}
className="up btn-round"
borderRadius="full"
border="none"
_hover={{
transform: "translateY(-2px)",
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
}}
_active={{ transform: "translateY(0)" }}
transition="all 0.2s"
>
访问新闻催化分析
</Button>
</Flex>
</Box>
</Box>
</Flex>
</Container>
</Box>
{/* AI投研专题应用区域 - Brainwave风格 */}
<Box as="section" py={20} bg="brainwave.n7" position="relative">
<Container maxW="container.xl">
<VStack spacing={12} textAlign="center">
<VStack spacing={2}>
<Heading size="xl" color="brainwave.n1" mb={0}>
AI投研专题应用
</Heading>
<Heading
size="xl"
bgGradient="linear(to-r, brainwave.orange, brainwave.purple)"
bgClip="text"
fontWeight="bold"
>
By 价小前投研
</Heading>
<Text fontSize="lg" color="brainwave.n2" fontWeight="medium">
人工智能+专业投研流程最强投资AI助手
</Text>
</VStack>
</VStack>
</Container>
</Box>
{/* 页脚 - Brainwave深色主题 */}
<Box as="footer" bg="brainwave.n8" color="brainwave.n1" py={16}>
<Container maxW="container.xl">
<SimpleGrid columns={{ base: 2, md: 5 }} spacing={8}>
{/* 价值前沿 */}
<VStack align="start" spacing={4}>
<Heading size="md" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">价值前沿</Heading>
<Text fontSize="sm" color="brainwave.n2" fontWeight="bold">
更懂投资者的AI投研平台
</Text>
</VStack>
{/* 关于我们 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.orange">关于我们</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>公司介绍</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>团队架构</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>联系方式</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>反馈评价</Link>
</VStack>
</VStack>
{/* 免费资源 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.green">免费资源</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>投研日报</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>资讯速递</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>免费试用</Link>
</VStack>
</VStack>
{/* 产品介绍 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.blue">产品介绍</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>行情复盘</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>高频跟踪</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>深研系统</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>了解更多</Link>
</VStack>
</VStack>
{/* 产品下载 */}
<VStack align="start" spacing={4}>
<Heading size="sm" color="brainwave.pink">产品下载</Heading>
<VStack align="start" spacing={2}>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>手机APP</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Win终端</Link>
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Mac终端</Link>
</VStack>
</VStack>
</SimpleGrid>
{/* 版权信息 */}
<Divider my={8} />
<Text textAlign="center" fontSize="sm" color="brainwave.n4">
All rights reserved. Copyright © {new Date().getFullYear()} 投研系统 by{' '}
<Link color="brainwave.orange" _hover={{ textDecoration: "underline" }}>
价值前沿
</Link>
.
</Text>
</Container>
</Box>
</Box>
);
}

View File

@@ -1,135 +0,0 @@
/** @type {import('tailwindcss').Config} */
import { fontFamily } from "tailwindcss/defaultTheme";
import plugin from "tailwindcss/plugin";
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./src/templates/**/*.{js,jsx,ts,tsx}",
"./src/components/**/*.{js,jsx,ts,tsx}",
"./src/views/**/*.{js,jsx,ts,tsx}",
"./public/index.html",
],
theme: {
extend: {
colors: {
color: {
1: "#AC6AFF",
2: "#FFC876",
3: "#FF776F",
4: "#7ADB78",
5: "#858DFF",
6: "#FF98E2",
},
stroke: {
1: "#26242C",
},
n: {
1: "#FFFFFF",
2: "#CAC6DD",
3: "#ADA8C3",
4: "#757185",
5: "#3F3A52",
6: "#252134",
7: "#15131D",
8: "#0E0C15",
},
},
fontFamily: {
sans: ["var(--font-sora)", ...fontFamily.sans],
code: "var(--font-code)",
grotesk: "var(--font-grotesk)",
},
letterSpacing: {
tagline: ".15em",
},
spacing: {
0.25: "0.0625rem",
7.5: "1.875rem",
15: "3.75rem",
},
opacity: {
15: ".15",
},
transitionDuration: {
DEFAULT: "200ms",
},
transitionTimingFunction: {
DEFAULT: "linear",
},
zIndex: {
1: "1",
2: "2",
3: "3",
4: "4",
5: "5",
},
borderWidth: {
DEFAULT: "0.0625rem",
},
backgroundImage: {
"radial-gradient": "radial-gradient(var(--tw-gradient-stops))",
"conic-gradient":
"conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876)",
},
},
},
plugins: [
plugin(function ({ addBase, addComponents, addUtilities }) {
addBase({});
addComponents({
".container": {
"@apply max-w-[77.5rem] mx-auto px-5 md:px-10 lg:px-15 xl:max-w-[87.5rem]":
{},
},
".h1": {
"@apply font-semibold text-[2.5rem] leading-[3.25rem] md:text-[2.75rem] md:leading-[3.75rem] lg:text-[3.25rem] lg:leading-[4.0625rem] xl:text-[3.75rem] xl:leading-[4.5rem]":
{},
},
".h2": {
"@apply text-[1.75rem] leading-[2.5rem] md:text-[2rem] md:leading-[2.5rem] lg:text-[2.5rem] lg:leading-[3.5rem] xl:text-[3rem] xl:leading-tight":
{},
},
".h3": {
"@apply text-[2rem] leading-normal md:text-[2.5rem]": {},
},
".h4": {
"@apply text-[2rem] leading-normal": {},
},
".h5": {
"@apply text-2xl leading-normal": {},
},
".h6": {
"@apply font-semibold text-lg leading-8": {},
},
".body-1": {
"@apply text-[0.875rem] leading-[1.5rem] md:text-[1rem] md:leading-[1.75rem] lg:text-[1.25rem] lg:leading-8":
{},
},
".body-2": {
"@apply font-light text-[0.875rem] leading-6 md:text-base":
{},
},
".caption": {
"@apply text-sm": {},
},
".tagline": {
"@apply font-grotesk font-light text-xs tracking-tagline uppercase":
{},
},
".quote": {
"@apply font-code text-lg leading-normal": {},
},
".button": {
"@apply font-code text-xs font-bold uppercase tracking-wider":
{},
},
});
addUtilities({
".tap-highlight-color": {
"-webkit-tap-highlight-color": "rgba(0, 0, 0, 0)",
},
});
}),
],
};

View File

@@ -1,109 +0,0 @@
"""
测试脚本:手动创建事件到数据库
用于测试 WebSocket 实时推送功能
"""
import sys
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, Float, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# 数据库连接(从 app.py 复制)
DATABASE_URI = 'mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4'
engine = create_engine(DATABASE_URI, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
Base = declarative_base()
# Event 模型(简化版,只包含必要字段)
class Event(Base):
__tablename__ = 'events'
id = Column(Integer, primary_key=True)
title = Column(String(500), nullable=False)
description = Column(Text)
event_type = Column(String(100))
importance = Column(String(10))
status = Column(String(50), default='active')
hot_score = Column(Float, default=0)
view_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def create_test_event():
"""创建一个测试事件"""
import random
event_types = ['policy', 'market', 'tech', 'industry', 'finance']
importances = ['S', 'A', 'B', 'C']
test_event = Event(
title=f'测试事件 - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
description=f'这是一个用于测试 WebSocket 实时推送的事件,创建于 {datetime.now()}',
event_type=random.choice(event_types),
importance=random.choice(importances),
status='active',
hot_score=round(random.uniform(50, 100), 2),
view_count=random.randint(100, 1000)
)
try:
session.add(test_event)
session.commit()
print("✅ 测试事件创建成功!")
print(f" ID: {test_event.id}")
print(f" 标题: {test_event.title}")
print(f" 类型: {test_event.event_type}")
print(f" 重要性: {test_event.importance}")
print(f" 热度: {test_event.hot_score}")
print(f"\n💡 提示: 轮询将在 2 分钟内检测到此事件并推送到前端")
print(f" (如果需要立即推送,请将轮询间隔改为更短)")
return test_event.id
except Exception as e:
session.rollback()
print(f"❌ 创建事件失败: {e}")
return None
finally:
session.close()
def create_multiple_events(count=3):
"""创建多个测试事件"""
print(f"正在创建 {count} 个测试事件...\n")
for i in range(count):
event_id = create_test_event()
if event_id:
print(f"[{i+1}/{count}] 事件 #{event_id} 创建成功\n")
else:
print(f"[{i+1}/{count}] 创建失败\n")
print(f"\n✅ 完成!共创建 {count} 个事件")
if __name__ == '__main__':
print("=" * 60)
print("WebSocket 事件推送测试 - 手动创建事件")
print("=" * 60)
print()
if len(sys.argv) > 1:
try:
count = int(sys.argv[1])
create_multiple_events(count)
except ValueError:
print("❌ 参数必须是数字")
print("用法: python test_create_event.py [数量]")
else:
# 默认创建 1 个事件
create_test_event()
print("\n" + "=" * 60)

147
test_events_api.py Normal file
View File

@@ -0,0 +1,147 @@
"""
测试 /api/events 接口的分页和交易日筛选功能
"""
import requests
from datetime import datetime, timedelta
# 接口地址
BASE_URL = "http://localhost:5001"
EVENTS_API = f"{BASE_URL}/api/events"
def test_pagination():
"""测试分页功能"""
print("\n=== 测试分页功能 ===")
# 测试第一页
params = {
'page': 1,
'per_page': 5
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
pagination = data['data']['pagination']
print(f"✓ 第一页请求成功")
print(f" - 当前页: {pagination['page']}")
print(f" - 每页数量: {pagination['per_page']}")
print(f" - 总记录数: {pagination['total']}")
print(f" - 总页数: {pagination['pages']}")
print(f" - 是否有下一页: {pagination['has_next']}")
print(f" - 本页事件数: {len(data['data']['events'])}")
# 测试第二页
if pagination['has_next']:
params['page'] = 2
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 第二页请求成功,返回 {len(data['data']['events'])} 个事件")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_trading_day_filter():
"""测试交易日筛选功能"""
print("\n=== 测试交易日筛选功能 ===")
# 测试使用 YYYY-MM-DD 格式
tday = "2024-11-01" # 使用一个交易日
params = {
'tday': tday,
'per_page': 10
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 交易日筛选成功 (格式: YYYY-MM-DD)")
print(f" - 交易日: {tday}")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
print(f" - 总记录数: {data['data']['pagination']['total']}")
if data['data']['events']:
print(f" - 第一个事件创建时间: {data['data']['events'][0]['created_at']}")
# 检查 applied_filters
filters = data['data']['filters']['applied_filters']
if 'tday' in filters:
print(f" - 应用的交易日筛选: {filters['tday']}")
else:
print(f"✗ 请求失败: {data.get('error')}")
# 测试使用 YYYY/M/D 格式
tday2 = "2024/11/1"
params['tday'] = tday2
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 交易日筛选成功 (格式: YYYY/M/D)")
print(f" - 交易日: {tday2}")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_combined_filters():
"""测试组合筛选功能"""
print("\n=== 测试组合筛选 (分页 + 交易日) ===")
params = {
'tday': '2024-10-31',
'page': 1,
'per_page': 3,
'status': 'active'
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 组合筛选成功")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
print(f" - 应用的筛选条件: {data['data']['filters']['applied_filters']}")
if data['data']['events']:
for i, event in enumerate(data['data']['events'], 1):
print(f" - 事件{i}: {event['title'][:30]}... (创建时间: {event['created_at']})")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_latest_trading_day():
"""测试获取最新数据(不传 tday 参数)"""
print("\n=== 测试获取最新数据 ===")
params = {
'page': 1,
'per_page': 5,
'sort': 'new'
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 获取最新数据成功")
print(f" - 返回事件数: {len(data['data']['events'])}")
if data['data']['events']:
print(f" - 最新事件: {data['data']['events'][0]['title'][:50]}")
print(f" - 创建时间: {data['data']['events'][0]['created_at']}")
else:
print(f"✗ 请求失败: {data.get('error')}")
if __name__ == "__main__":
print("开始测试 /api/events 接口")
print("=" * 50)
try:
# 测试各项功能
test_pagination()
test_trading_day_filter()
test_combined_filters()
test_latest_trading_day()
print("\n" + "=" * 50)
print("测试完成!")
except requests.exceptions.ConnectionError:
print("\n✗ 连接失败:请确保后端服务正在运行 (python app.py)")
except Exception as e:
print(f"\n✗ 测试出错: {e}")