Compare commits

..

348 Commits

Author SHA1 Message Date
zdl
a2c5c8bb47 chore: 移除 ApexCharts 依赖
- 删除 apexcharts 和 react-apexcharts 包
- 所有图表已迁移到 ECharts,不再需要 ApexCharts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-24 12:07:38 +08:00
zdl
292d3a007a refactor(TradingSimulation): 迁移 ApexCharts 图表到 ECharts
图表组件迁移:
  - AssetTrendChart: 资产走势折线图 → ECharts 面积图
  - AssetAllocationChart: 资产配置环形图 → ECharts 饼图
  - PositionDistributionChart: 持仓分布饼图 → ECharts 饼图
  - ProfitAnalysisChart: 盈亏分析柱状图 → ECharts 柱状图

  删除的 ApexCharts 组件:
  - src/components/Charts/LineChart.js
  - src/components/Charts/BarChart.js
  - src/components/Charts/PieChart.js
  - src/components/Charts/DonutChart.js

  技术改进:
  - 统一使用 ECharts 作为通用图表库
  - 新组件使用 TypeScript,类型安全
  - 为后续移除 apexcharts 依赖做准备
2025-12-24 12:06:26 +08:00
zdl
a27065e613 feat: 删除旧文件 2025-12-24 11:49:24 +08:00
zdl
12fc63bef9 chore: 删除未使用的 ApexCharts 模板组件和演示页面
删除的组件:
- src/components/Charts/BubbleChart.js
- src/components/Charts/LineBarChart.js
- src/components/Charts/PolarChart.js
- src/components/Charts/RadarChart.js

删除的模板演示页面:
- src/views/Pages/Charts.js
- src/views/Pages/Projects/General.js
- src/views/Pages/RTLPage.js

这些文件来自 Argon Dashboard Chakra PRO 模板,从未被路由引用或使用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-24 11:28:11 +08:00
zdl
ac76db09a2 feat(HomePage): 已登录用户访问首页展示个人中心内容
- HomePage: 添加条件渲染,已登录时展示 Center 组件
- 重构 Center 目录结构,合并 Center.tsx 到 index.tsx
- 重命名 CenterDashboard 为 Center(lazy-components, homeRoutes)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-24 11:06:30 +08:00
zdl
9156da410d feat(WatchSidebar): 面板 UI 优化,添加日均周涨展示
- WatchlistPanel: 添加 hideTitle 支持,新增日均/周涨 Badge 展示
- FollowingEventsPanel: 添加 hideTitle 支持,兼容 related_avg_chg 字段
- FollowingEventsMenu: 使用 FavoriteButton 替代文字按钮
- 统一卡片样式,与关注事件面板保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
zdl
073fba5c57 fix(GlobalSidebar): Popover 弹窗隐藏面板内部标题
- WatchlistPanel 和 FollowingEventsPanel 传入 hideTitle={true}
- 避免与 PopoverHeader 标题重复显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
zdl
06475f82a4 refactor(events): 关注事件数据源统一到 Redux
- useFollowingEvents: 改用 Redux selector 获取关注事件
- GlobalSidebarContext: 移除本地 followingEvents 状态,使用 Redux
- 侧边栏和导航栏共享同一数据源,保持状态同步

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
zdl
b578504591 refactor(watchlist): 自选股数据源统一到 Redux
- stockSlice: 新增 loadWatchlistQuotes thunk 加载自选股行情
- useWatchlist: 改用 Redux selector 获取自选股数据
- WatchlistMenu: 使用 Redux 数据源,移除本地状态管理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
zdl
60aa5c80a5 style(EventDetailModal): 优化弹窗 Tab 和关闭按钮样式
- Tab 颜色方案调整:全部=紫罗兰色,计划=金色,复盘=绿色,系统=蓝色
- 未选中态统一使用银白色主题
- 选中态增强高亮效果(boxShadow、更明显的边框)
- 标题颜色调整为 rgba(255,255,255,0.85)
- 关闭按钮增强可见性:添加背景色、固定尺寸、圆角
- 弹窗背景色与 /home/center 页面保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
zdl
a332d5571a docs: 精简 CLAUDE.md 优化 Claude Code 性能
- 将文件从 61KB 精简到 5.5KB(减少 91%)
- 删除冗长的目录结构详解,改为表格速查
- 删除大量代码示例,保留核心概念
- 引用独立文档(TYPESCRIPT_MIGRATION.md)替代详细内容
- 保留技术栈、命令、目录结构、开发工作流等核心信息

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 20:17:57 +08:00
1eb94cc213 更新Company页面的UI为FUI风格 2025-12-23 17:53:21 +08:00
zdl
e0e1e7e444 Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock
* 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react:
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
2025-12-23 17:46:45 +08:00
zdl
f1ae48bd42 fix(Layout): 全局布局优化与 Mock 数据增强 2025-12-23 17:45:19 +08:00
zdl
602dcf8eee style(Profile): 用户中心 UI 紧凑化与布局优化 2025-12-23 17:45:03 +08:00
zdl
d9dbf65e7d refactor(Concept): ConceptTimelineModal 迁移到 BaseCalendar 2025-12-23 17:44:48 +08:00
zdl
12a57f2fa2 refactor(Center): 重构投资规划中心日历与事件管理 2025-12-23 17:44:35 +08:00
zdl
39fb70a1eb feat(Calendar): 新增公共日历组件 BaseCalendar 2025-12-23 17:44:20 +08:00
zdl
068d59634b chore: 清理废弃的组件和样式文件 2025-12-23 17:44:06 +08:00
zdl
4cae6fe5b6 fix(mock): 修复主线数据不显示问题
- 调整 MSW handler 顺序,确保 /api/events/mainline 在 :eventId 之前匹配
  - 修复 generateDynamicNewsEvents 函数调用参数顺序错误
  - 添加主线事件模板,确保生成的事件能匹配主线关键词
  - 删除重复的 mainline handler 代码
  - 清理调试日志
2025-12-23 17:34:20 +08:00
zdl
145b6575d8 feat(MarketDashboard): 添加市场概况卡片(上证/深证/总市值/成交额)
新增组件:
- MarketSummaryCard: 紧凑型 2x2 网格布局
  - 上证指数:价格、涨跌额、涨跌幅
  - 深证指数:价格、涨跌额、涨跌幅
  - 总市值:万亿级格式化显示
  - 成交额:万亿级格式化显示

布局更新:
- MarketOverview: 从 3 列扩展为 4 列
- 市场概况卡片位于最左侧

Mock API:
- /api/market/summary: 返回实时市场概况数据
- 数据基于时间产生小波动,模拟真实行情

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 15:28:48 +08:00
zdl
7d859e18ca style(HotSectorsRanking): 统一与关注股票面板 UI 风格
- 移除外层装饰盒子(背景、边框、毛玻璃效果)
- 标题行添加 TrendingUp 图标 + 数量显示
- 列表项添加 hover 效果和 cursor: pointer
- 滚动条样式与 WatchlistPanel 一致
- 涨跌幅统一为 2 位小数
- 新增 onSectorClick 回调支持板块点击

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:57:12 +08:00
zdl
939b4e736c docs(CLAUDE): 更新项目目录结构文档
核心目录概览:
- 添加 types/、devtools/、bytedesk-integration/ 等新目录
- 完善 store/、services/、utils/ 子目录结构
- 详细展示 assets/、theme/、mocks/ 子目录

Views 目录:
- 完整列出 18 个页面模块
- 详细展示 AgentChat、Company、Community 等复杂模块结构

Components 目录:
- 更新为实际的按功能分类结构
- 列出 50+ 个组件目录

其他更新:
- 更新 Contexts 列表(添加 GlobalSidebarContext)
- 更新 Redux Slices 列表(添加 planningSlice、deviceSlice)
- 更新 Services 列表(20+ 服务文件)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:51:15 +08:00
zdl
b2ade04b00 fix(EventPanel): 优化响应式网格布局
- 调整 Grid templateColumns 响应式断点
- base: 1列 → sm: 2列 → lg: 3列
- 提升大屏幕下的空间利用率

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:51:02 +08:00
zdl
6a21a57f4c style(global): 添加全局滚动条隐藏样式
- 新增 scrollbar-hide.css 隐藏所有滚动条
- 支持 Firefox (scrollbar-width)、Chrome/Safari (webkit)、IE/Edge
- 保留滚动功能,仅隐藏滚动条视觉元素
- 在 index.js 中全局导入

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:50:53 +08:00
zdl
2fe535e553 style(layout): 完善 Z-INDEX 层级管理,优化全局侧边栏样式
layoutConfig.js:
- 重构 Z_INDEX 常量,分层管理(页面内部 → 系统级)
- 添加详细注释说明各层级用途
- 新增 SIDEBAR、DROPDOWN、POPOVER 等层级定义

GlobalSidebar:
- 使用统一的 Z_INDEX.SIDEBAR 常量
- 优化背景色和边框样式
- 添加 h="100%" 确保高度填满

MainLayout:
- 简化注释

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:50:41 +08:00
zdl
d24f9c7b16 feat(Center): 投资规划中心新建计划/复盘乐观更新
- planningSlice: 添加 optimisticAddEvent、replaceEvent、removeEvent reducers
- EventFormModal: 新建模式使用乐观更新,立即关闭弹窗显示数据
- account.js: Mock 数据按日期倒序排序,最新事件在前

乐观更新流程:
1. 创建临时事件(负数 ID)立即更新 UI
2. 后台发送 API 请求
3. 成功后替换为真实数据,失败则回滚

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:50:29 +08:00
zdl
ab5b19847f refactor(Planning): 投资规划中心重构为 Redux 状态管理
- 新增 planningSlice 管理计划/复盘数据
- InvestmentPlanningCenter 改用 Redux 而非本地 state
- 列表和日历视图共享同一数据源,保持同步
- 优化 Mock handlers,改进事件 ID 生成和调试日志

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:15:49 +08:00
zdl
0b683f4227 fix(HomePage): 修复页面高度为自适应
- 移除固定的 heroHeight (60vh/80vh/100vh)
- 改用 minH=100% 自适应容器高度
- 修复页面不必要的滚动问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:15:36 +08:00
zdl
fd5b74ec16 refactor(HotSectors): 热门板块从仪表盘移至全局工具栏
- WatchSidebar 展开状态添加热门板块模块
- MarketOverview 移除热门板块,布局从 4 列改为 3 列
- 避免热门板块在页面和工具栏重复显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:15:29 +08:00
zdl
92e6fb254b feat(GlobalSidebar): 收起状态添加 Popover 悬浮弹窗
- 收起状态点击图标显示悬浮弹窗,无需展开侧边栏
- 添加关注股票、关注事件、热门板块三个 Popover 面板
- 展开状态添加独立标题栏 [>] 工具栏
- 移除收起按钮的 Tooltip 提示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 14:15:20 +08:00
zdl
c325d51316 fix(MainLayout): 调整页脚位置到滚动区域内
- 将 AppFooter 移动到内容滚动区域内
- 页脚随内容滚动,不再固定在底部
- 适配全局侧边栏布局

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:58:18 +08:00
zdl
a41cd71a65 style(Center): 日历组件样式微调
- CalendarPanel: 优化金色渐变标题效果
- InvestmentCalendar.less: 调整间距和边框样式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:57:40 +08:00
zdl
3dabddf222 style(fullcalendar): 适配黑金主题色
- 按钮背景色从紫色改为金色 (#D4AF37)
- 按钮文字改为深色 (#0A0A14)
- hover/active 状态使用深金色 (#B8960C)
- focus 阴影改为金色调

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:57:16 +08:00
zdl
89ed59640e feat(WatchSidebar): 增强关注事件面板功能
- FollowingEventsPanel: 添加取消关注功能 (onUnfollow)
- FollowingEventsPanel: 显示日涨跌和周涨跌两个指标
- WatchlistPanel: 优化布局和样式
- index.js: 导出 useGlobalSidebar hook

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:56:53 +08:00
zdl
dafef2c572 refactor(Center): 大幅简化,移除侧边栏逻辑
- 移除 WatchSidebar 相关代码(已移至全局 GlobalSidebar)
- 移除数据加载逻辑(由 GlobalSidebarContext 统一管理)
- 移除 useAuth、useLocation、useNavigate 等依赖
- 保留核心功能:MarketDashboard、ForumCenter、InvestmentPlanningCenter
- 代码从 ~260 行精简至 ~40 行

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:56:30 +08:00
zdl
bb0506b2bb feat(layouts): MainLayout 集成全局右侧工具栏
- 主体区域改为 Flex 布局(左侧内容 + 右侧侧边栏)
- 添加 GlobalSidebar 组件到右侧
- 页面内容区域自适应宽度

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:56:07 +08:00
zdl
8b9e35e55c feat(providers): 集成 GlobalSidebarProvider
- 在 AuthProvider 内层添加 GlobalSidebarProvider
- 确保侧边栏可以访问用户认证状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:55:45 +08:00
zdl
5ca19d11a4 feat(components): 新增 GlobalSidebar 全局右侧工具栏
- 可收起/展开的侧边栏设计
- 收起状态显示图标菜单(股票数量、事件数量 Badge)
- 展开状态复用 WatchSidebar 组件
- 支持未登录状态提示
- 毛玻璃背景 + 金色主题装饰

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:55:19 +08:00
zdl
7a079a86b1 feat(contexts): 新增 GlobalSidebarContext 全局侧边栏状态管理
- 管理侧边栏展开/收起状态 (isOpen, toggle)
- 统一管理数据加载(自选股、关注事件、评论)
- 实时行情定时刷新(每分钟)
- 页面可见性变化时自动刷新数据
- 用户登录/登出时自动管理数据生命周期

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 11:54:54 +08:00
zdl
0a9ae6507b refactor(Profile): 删除废弃的 StrategyCenter 模块
删除以下文件:
- StrategyCenter/index.js
- StrategyCenter/components/index.js
- StrategyCenter/components/AITradingCard.js
- StrategyCenter/components/DefenseStrategyCard.js
- StrategyCenter/components/QuarterPlanCard.js
- StrategyCenter/components/ReviewCard.js

该模块功能已整合到 InvestmentPlanningCenter 中

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:56:14 +08:00
zdl
22d731167c refactor(Center): 移除 StrategyCenter,简化布局
- 移除 StrategyCenter 组件引用
- 移除右侧边栏的滚动条样式(由子组件自行处理)
- 更新投资规划中心注释说明

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:55:46 +08:00
zdl
600d9cc846 refactor(WatchSidebar): 优化三个子组件
- FollowingEventsPanel: 添加滚动区域容器,移除数量限制
- MyCommentsTab: 添加滚动区域,移除 maxDisplay 限制
- WatchlistPanel: 优化滚动区域样式和布局

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:55:21 +08:00
zdl
dcba97a121 fix(Center): EventPanel 和 EventDetailModal 细节调整
- EventPanel: 调整组件引用和布局
- EventDetailModal: 引入样式文件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:54:53 +08:00
zdl
a2a15e45a4 refactor(InvestmentPlanningCenter): 重构为 GlassCard 毛玻璃风格
- 移除 Chakra Card 组件,改用 GlassCard
- 标题添加金色渐变效果
- 视图切换按钮改为金色主题
- 使用 lucide-react 的 Target 图标替换 FiTarget
- 整体适配 FUI 黑金设计风格

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:54:28 +08:00
zdl
4f6bfe0b8c style(InvestmentCalendar): 优化日历组件样式
- 调整日历单元格样式和间距
- 优化事件标签显示效果
- 适配黑金主题配色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:53:58 +08:00
zdl
bbd965a307 style(EventFormModal): 升级为黑金主题样式
- 股票选择器下拉菜单适配深色背景
- Tag 标签改为金色边框和背景
- 自选股提示文字颜色优化
- 添加 ConfigProvider 深色主题支持
- Less 样式文件大幅扩展,支持完整黑金风格

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:53:31 +08:00
zdl
f557ef96cf style(CalendarPanel): 适配黑金主题色
- FullCalendar 按钮改为金色(#D4AF37)
- 今日日期高亮边框改为金色
- 标题添加金色渐变效果
- 事件颜色:计划用金色,复盘用青色,系统事件用蓝色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:53:04 +08:00
zdl
b6ed68244e style(Center): 新增 EventDetailModal 样式文件
- 添加事件详情弹窗的独立样式
- 配合黑金主题设计

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:52:33 +08:00
zdl
e93d5532bf feat(Center): 新增 FUIEventCard 毛玻璃风格事件卡片
- 融合 ReviewCard 的 UI 风格(毛玻璃 + 金色主题)
- 支持编辑、删除、展开描述等功能
- 使用 FUI_THEME 常量统一管理主题色
- 用于复盘列表的高级视觉呈现

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:52:06 +08:00
zdl
429737c111 chore(GlassCard): 添加 TypeScript 类型声明文件
- 定义 GlassCardProps 接口(variant, hoverable, glowing 等属性)
- 定义 GlassTheme 类型(colors, blur, glow 主题配置)
- 导出 GLASS_THEME 常量类型

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-23 10:51:38 +08:00
9750ab75ba 更新Company页面的UI为FUI风格 2025-12-23 10:48:33 +08:00
zdl
93928f4ee7 feat(WatchSidebar): 恢复评论模块,添加 Tab 切换
- 在关注事件面板添加"我的评论" Tab
 - 新增 MyCommentsTab 组件显示用户评论
 - 评论显示:内容、关联事件、点赞/回复数、时间
 - 更新类型定义支持评论数据传递
2025-12-23 10:05:45 +08:00
zdl
30b831e880 refactor(MarketDashboard): 重构投资仪表盘布局
- 上证指数、深证成指使用 K 线图,与事件中心一致
 - 移除成交额模块
 - 创业板指与涨跌分布上下组合
 - 涨跌分布改用进度条样式
 - 布局从 6 列改为 4 列
2025-12-23 10:05:28 +08:00
8a9e4f018a Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock 2025-12-23 09:50:11 +08:00
a626c6c872 更新Company页面的UI为FUI风格 2025-12-23 09:50:04 +08:00
zdl
18ba36a539 refactor(Center): 全面优化个人中心模块
- 目录重命名:Dashboard → Center(匹配路由 /home/center)
- 删除遗留代码:Default.js、InvestmentPlansAndReviews.js、InvestmentCalendarChakra.js(共 2596 行)
- 创建 src/types/center.ts 类型定义(15+ 接口)
- 性能优化:
  - 创建 useCenterColors Hook 封装 7 个 useColorModeValue
  - 创建 utils/formatters.ts 提取纯函数
  - 修复 loadRealtimeQuotes 的 useCallback 依赖项
  - InvestmentPlanningCenter 添加 useMemo 缓存
- TypeScript 迁移:Center.js → Center.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:09 +08:00
zdl
c639b418f0 refactor(Center): 重构个人中心为左右布局
- 左侧自适应:投资仪表盘、规划中心、论坛
- 右侧固定200px:关注股票、关注事件
- 使用 THEME 黑金配色
- 宽度与导航栏保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:09 +08:00
zdl
712090accb feat(WatchSidebar): 新增右侧边栏组件
- 关注股票面板(独立模块)
- 关注事件面板(独立模块)
- 固定200px宽度,粘性定位

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
bc844bb4dc feat(ForumCenter): 新增价值论坛/互动中心组件
- 我的预测卡片(看涨/看跌投票)
- 社区动态卡片(我发布的/我参与的)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
10e34d911f feat(StrategyCenter): 新增投资规划中心组件
- Q1计划卡片(进度条+要点列表)
- 银行股防守卡片(仓位+策略)
- AI算力交易卡片(浮盈数据)
- 消费复盘卡片(趋势图+心得)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
1a55e037c9 feat(MarketDashboard): 新增投资仪表盘组件
- 指数卡片组件(带迷你面积图)
- 成交额柱状图、涨跌分布图组件
- 热门板块排行组件
- 毛玻璃背景,黑金配色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
zdl
16c30b45b9 feat(GlassCard): 新增通用毛玻璃卡片组件
- 支持多种变体: default, elevated, subtle, transparent
- 支持悬停效果、发光效果、角落装饰
- 黑金配色主题,可全局复用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 18:59:08 +08:00
317bdb1daf 更新Company页面的UI为FUI风格 2025-12-22 17:38:56 +08:00
5843029b9c 更新Company页面的UI为FUI风格 2025-12-22 17:24:19 +08:00
0b95953db9 更新Company页面的UI为FUI风格 2025-12-22 17:19:52 +08:00
3ef1e6ea29 更新Company页面的UI为FUI风格 2025-12-22 17:10:55 +08:00
8936118133 更新Company页面的UI为FUI风格 2025-12-22 16:25:36 +08:00
1071405aaf 更新Company页面的UI为FUI风格 2025-12-22 16:19:26 +08:00
144cc256cf 更新Company页面的UI为FUI风格 2025-12-22 16:13:14 +08:00
82e4fab55c 更新Company页面的UI为FUI风格 2025-12-22 15:57:07 +08:00
22c5c166bf 更新Company页面的UI为FUI风格 2025-12-22 15:48:36 +08:00
61a29ce5ce 更新Company页面的UI为FUI风格 2025-12-22 15:43:08 +08:00
20bcf3770a 更新Company页面的UI为FUI风格 2025-12-22 15:31:10 +08:00
6d878df27c Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock 2025-12-22 13:24:45 +08:00
a2a233bb0f 更新Company页面的UI为FUI风格 2025-12-22 13:24:39 +08:00
zdl
174fe32850 feat(LoadingState): 新增骨架屏变体,优化加载体验
- LoadingState: 新增 variant 参数支持 spinner/skeleton 模式
- LoadingState: 新增 skeletonType 参数支持 grid/list 布局
- AnnouncementsPanel: 使用 list 骨架屏替代 spinner
- DisclosureSchedulePanel: 使用 grid 骨架屏替代 spinner

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 13:04:56 +08:00
zdl
77ea38e5c9 perf(hooks): 使用 useRef 缓存加载状态,避免 Tab 切换重复请求
- 使用 useRef 替代 useState 跟踪 hasLoaded 状态
- Tab 切换回来时保持数据缓存,不重新发起请求
- stockCode 变化时重置加载状态,确保新股票正常加载
- useAnnouncementsData 支持 refreshKey 强制刷新

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 13:04:55 +08:00
zdl
9e271747da perf(MarketDataView): 优化加载状态,使用骨架屏避免布局跳动
- useMarketData: 新增 hasLoaded 状态,优化首次加载 loading 逻辑
- 导出 SummaryCardSkeleton 组件用于概览卡片占位
- MarketDataView: 使用骨架屏替代空白占位
- DeepAnalysisTab: 使用 skeleton 变体替代 spinner

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 13:04:55 +08:00
zdl
88b836e75a fix(mock): 完善大宗交易和龙虎榜数据结构
- 融券余额增加 balance_amount 字段
- 大宗交易:新增 deals 明细、买卖营业部、成交均价
- 龙虎榜:新增 buyers/sellers 营业部列表、净买入金额

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-22 13:04:55 +08:00
307d80c808 更新Company页面的UI为FUI风格 2025-12-22 12:58:01 +08:00
897067a94e 更新Company页面的UI为FUI风格 2025-12-22 12:14:57 +08:00
da02461965 更新Company页面的UI为FUI风格 2025-12-22 11:52:30 +08:00
efe5f45e31 更新Company页面的UI为FUI风格 2025-12-22 11:08:45 +08:00
96c94eaec4 更新Company页面的UI为FUI风格 2025-12-22 10:41:54 +08:00
23dd573663 更新Company页面的UI为FUI风格 2025-12-22 10:21:49 +08:00
2d48e08e43 更新Company页面的UI为FUI风格 2025-12-22 09:52:02 +08:00
46c7649bf0 更新Company页面的UI为FUI风格 2025-12-22 08:01:41 +08:00
ee734e719e 更新Company页面的UI为FUI风格 2025-12-22 07:48:16 +08:00
453c2f8635 更新Company页面的UI为FUI风格 2025-12-22 00:19:44 +08:00
d7429b94ae 更新Company页面的UI为FUI风格 2025-12-22 00:13:30 +08:00
fec478f361 更新Company页面的UI为FUI风格 2025-12-22 00:08:01 +08:00
79ec798abf 更新Company页面的UI为FUI风格 2025-12-22 00:05:26 +08:00
f09062491e 更新Company页面的UI为FUI风格 2025-12-22 00:02:14 +08:00
19ca71068b 更新Company页面的UI为FUI风格 2025-12-21 23:55:34 +08:00
840ed920b8 更新Company页面的UI为FUI风格 2025-12-21 23:52:34 +08:00
9baa57a15d 更新Company页面的UI为FUI风格 2025-12-21 23:43:41 +08:00
54b7d9fc89 更新Company页面的UI为FUI风格 2025-12-21 23:40:02 +08:00
d9b804c46c 更新Company页面的UI为FUI风格 2025-12-21 23:22:33 +08:00
e177de647d 更新Company页面的UI为FUI风格 2025-12-21 19:49:14 +08:00
b61f7a5048 更新Company页面的UI为FUI风格 2025-12-21 19:29:42 +08:00
zdl
d74162b7ce fix(CompanyOverview): 修复 React Strict Mode 下骨架屏闪现问题
- 移除所有 hooks 中的 finally 块,避免请求取消时错误更新状态
- 添加 hasLoaded 状态追踪首次加载完成
- CanceledError 时直接返回,不更新任何状态
- 使用派生 isLoading 状态确保骨架屏正确显示

修复的 hooks:
- useShareholderData.ts
- useManagementData.ts
- useAnnouncementsData.ts
- useDisclosureData.ts
- useBasicInfo.ts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:58:53 +08:00
zdl
bea4c7fe81 perf(MarketDataView): 优化数据映射性能和请求管理
- useMarketData: 使用 Map 替代 findIndex,O(n*m) → O(n+m) 性能优化
- useMarketData: 修复 React StrictMode 下请求被意外取消的问题
- config.ts: 添加 CompanyOverview 和 DynamicTracking 的骨架屏 fallback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:06 +08:00
zdl
d3f4a8e02c perf(DynamicTracking): 子面板支持延迟加载和骨架屏
- ForecastPanel/NewsPanel 接收 isActive 和 activationKey 控制数据加载
- 使用骨架屏替代 Spinner 加载状态
- Tab 切换时自动刷新数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:05 +08:00
zdl
90e2a48d66 feat(BasicInfoTab): 添加骨架屏并适配延迟加载
- 各 Panel 组件适配新的 hooks 参数格式
- 新增 BasicInfoTabSkeleton 骨架屏组件
- 新增 CompanyOverviewNavSkeleton 导航骨架屏组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:05 +08:00
zdl
298ac5a335 perf(CompanyOverview): hooks 支持 enabled 延迟加载和刷新
- 所有 hooks 参数改为 options 对象形式
- 新增 enabled 参数支持延迟加载
- 新增 refreshKey 参数支持手动刷新
- 智能初始化 loading 状态,避免首次渲染闪现空状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
672e746a26 feat(SubTabContainer): 支持 Tab 激活状态和刷新机制
- SubTabContainer: 新增 isActive 和 activationKey props 传递给子组件
- SubTabContainer: 修复 Tab 切换时页面滚动位置跳转问题
- TabPanelContainer: 新增 skeleton prop 支持自定义骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
88da7ad1a5 fix(mock): 完善股票名称映射,支持多只股票
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
8c9cc9845d perf(DynamicTracking): 优化组件加载体验,子组件懒加载
- 使用 React.lazy() 懒加载所有子面板组件
- 为每个 Tab 添加专属骨架屏 fallback
- SubTabContainer 同步渲染,点击立即显示二级导航
- 添加 memo、useCallback、useMemo 性能优化
- 新增 DynamicTrackingSkeleton.tsx 骨架屏组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
11544909d3 style(MarketDataView): 缩小页面间距
- Container py: 6 → 4
- VStack spacing: 6 → 4

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
08842b9097 fix: 优化加载状态和布局
MarketDataView:
- 移除重复的 LoadingState,改用 KLineModule 内部骨架屏
- 修复点击股票行情后数据不显示的问题

FinancialPanorama:
- 移除表格右上角"显示 6 期"标签
- 优化 loadingTab 状态处理

SubTabContainer:
- 重构布局:Tab 区域可滚动,右侧元素固定

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
0ad0287f7b fix(FinancialPanorama): 优化期数切换和数据加载
- Tab 切换时检查期数是否一致,不一致则重新加载
- 股票切换时立即清空旧数据,确保显示骨架屏
- 表格右上角显示当前期数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:03 +08:00
zdl
d394c25d7e feat(MarketDataView): 添加股票行情骨架屏
- 创建 MarketDataSkeleton 组件(摘要卡片 + K线图表 + Tab)
- 配置 Suspense fallback,点击时直接显示骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:03 +08:00
7fd1dc34f4 更新Company页面的UI为FUI风格 2025-12-19 15:53:46 +08:00
zdl
6776e1d557 feat(SubTabContainer): 支持自定义 Suspense fallback
- SubTabConfig 添加 fallback 属性
- 财务全景/盈利预测配置骨架屏 fallback
- 解决点击 Tab 先显示 Spinner 再显示骨架屏的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 15:23:56 +08:00
zdl
6eec7c6402 feat(ForecastReport): 添加盈利预测骨架屏
- 创建 ForecastSkeleton 组件(图表卡片 + 表格)
- 初始加载时显示骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 15:18:00 +08:00
zdl
27b0e9375a feat(FinancialPanorama): 添加页面骨架屏
- 创建 FinancialPanoramaSkeleton 组件
- 初始加载时显示完整骨架屏(概览面板、图表、主营业务、Tab)
- 优化加载体验,避免内容闪烁

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 15:13:24 +08:00
zdl
e71f42b608 style(FinancialPanorama): 优化 UI 布局
- Tab 组件:移除重复的标题区域(Tab 栏已有标题)
- Table 组件:眼睛图标改为"趋势"按钮,更明确的交互提示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 15:06:50 +08:00
zdl
2c1acb41b4 refactor(DynamicTracking): 将 NewsEventsTab 移至正确目录并重构
- 从 CompanyOverview/ 移动到 DynamicTracking/(修复跨目录引用)
- 拆分为目录结构:constants.ts, types.ts, utils.ts
- 提取 5 个子组件:NewsSearchBar, NewsEventCard, NewsPagination,
  NewsEmptyState, NewsLoadingState
- 转换为 TypeScript,添加完整类型定义(ThemeConfig, NewsEvent 等)
- 所有子组件使用 React.memo 优化渲染
- 更新 NewsPanel.js 引用路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 15:03:36 +08:00
zdl
23788bbebf refactor(CompanyOverview): 删除透传组件,直接使用 BasicInfoTab
- config.ts: 直接 lazy import BasicInfoTab
- CompanyTabs: 直接导入 BasicInfoTab
- 删除 CompanyOverview/index.tsx(仅透传无逻辑)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:47:12 +08:00
zdl
2cc16be585 docs(FinancialPanorama): 更新组件文档
- 更新目录结构说明
- 新增性能优化章节(memo、共享主题、组件提取等)
- 更新组件层级图
- 新增数据流图
- 新增懒加载策略说明

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:45:08 +08:00
zdl
11ca0e7a99 refactor(FinancialPanorama): 简化 useFinancialData Hook
- 移除未使用的 forecast 状态
- 移除未使用的 industryRank 状态
- 简化返回值类型定义

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:54 +08:00
zdl
ff951972ee refactor(FinancialPanorama): 优化主组件 Props 传递
- 使用 MetricChartModal 替代内联 Modal
- 简化 showMetricChart 回调
- componentProps 使用展开语法传递颜色常量
- 简化 useMemo 依赖数组
- 移除未使用的 imports

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:42 +08:00
zdl
41da6fa372 perf(FinancialPanorama): Tab 组件添加 memo 优化
- MetricsCategoryTab: 使用共享主题,主组件和 7 个子组件添加 memo
- BalanceSheetTab: 添加 memo
- IncomeStatementTab: 添加 memo
- CashflowTab: 添加 memo
- FinancialMetricsTab: 添加 memo
- 减少不必要的重渲染

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:32 +08:00
zdl
54cce55c29 perf(FinancialPanorama): 表格组件使用共享配置 + memo
- BalanceSheetTable: 使用共享主题,添加 memo
- IncomeStatementTable: 使用共享主题,添加 memo
- CashflowTable: 使用共享主题,添加 memo
- 移除内联主题定义,减少重复代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:26 +08:00
zdl
0e29f1aff4 refactor(FinancialPanorama): 提取 MetricChartModal 组件
- 从 index.tsx 提取独立的指标图表弹窗组件
- 使用 memo 包装优化性能
- 包含图表展示和同比/环比计算表格
- 减少主组件约 100 行代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:20 +08:00
zdl
7b58f83490 refactor(FinancialPanorama): 提取共享表格主题配置
- 新增 utils/tableTheme.ts 统一黑金主题配置
- BLACK_GOLD_TABLE_THEME: Ant Design ConfigProvider 主题
- getTableStyles(): CSS 样式工厂函数
- calculateYoY(): 同比计算共享函数
- 消除约 200 行重复代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:13 +08:00
zdl
22062a6556 perf(PledgePanel): 添加 useMemo 缓存图表配置
- 使用 useMemo 缓存 getPledgeDarkGoldOption 计算结果
- 避免每次渲染重新计算图表配置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:23:47 +08:00
zdl
94854fee3e refactor(MarketDataView): 提取 DataRow 原子组件,样式统一
- 新增 shared/DataRow.tsx:通用数据行组件(支持 gold/orange/red/green 变体)
- 新增样式常量:financingRowStyle, securitiesRowStyle, buyRowStyle, sellRowStyle, dayCardStyle
- FundingPanel: 使用 useMemo 缓存图表配置和数据,使用 DataRow 替代重复结构
- BigDealPanel: 使用 dayCardStyle 替代内联样式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:20:46 +08:00
zdl
852d5fd188 refactor(ForecastReport): 架构优化与性能提升
阶段一 - 核心优化:
- 所有子组件添加 React.memo 防止不必要重渲染
- 图表组件统一使用 EChartsWrapper 替代 ReactECharts
- 提取 isForecastYear、IMPORTANT_METRICS 到 constants.ts
- DetailTable 样式提取为 DETAIL_TABLE_STYLES 常量

阶段二 - 架构优化:
- 新增 hooks/useForecastData.ts:数据获取 + Map 缓存 + AbortController
- 新增 services/forecastService.ts:API 封装层
- 新增 utils/chartFormatters.ts:图表格式化工具函数
- 主组件精简:79行 → 63行,添加错误处理和重试功能

优化效果:
- 消除 4 处 isForecastYear 重复定义
- 样式从每次渲染重建改为常量复用
- 添加请求缓存,避免频繁切换时重复请求

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:17:21 +08:00
zdl
4e71623477 docs(Company): 添加组件模块总览 README
新增 Company/components/README.md:
- 组件概览表格(7 个核心组件)
- 完整目录结构说明
- 组件层级关系图
- 技术栈和主题系统说明
- 使用示例

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:44:03 +08:00
zdl
ce4da40ef6 refactor(DeepAnalysis): TypeScript 重构,提取 useDeepAnalysisData Hook
- 新增 types.ts:API 类型定义、状态接口、Tab 映射常量
- 新增 hooks/useDeepAnalysisData.ts:提取数据获取逻辑
  - 懒加载:按 Tab 按需请求
  - 数据缓存:已加载数据不重复请求
  - 竞态处理:stockCode 变更时防止旧请求覆盖
- 重写 index.tsx:memo 优化,代码行数 229 → 81
- 新增 README.md:组件文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:42:16 +08:00
zdl
bff440ff8a refactor(TradeDataPanel): 拆分 KLineModule 为独立子组件
- KLineModule: 611行精简至157行,专注状态管理
- 提取 KLineToolbar: 工具栏组件(模式切换、指标选择)
- 提取 DailyKLineChart: 日K图表(useMemo缓存配置)
- 提取 MinuteChartWithOrderBook: 分时图+五档盘口
- 提取 constants.ts: 指标选项常量
- 提取 styles.ts: 按钮样式常量
- 所有组件使用 React.memo 优化
- 更新 README.md 文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:37:15 +08:00
zdl
9ef206a9e7 docs(Company): 添加组件目录结构文档
为 Company 模块下的主要组件添加 README.md:
- CompanyHeader: 搜索栏组件
- CompanyOverview: 公司概览(基本信息 + 深度分析)
- FinancialPanorama: 财务全景
- ForecastReport: 盈利预测
- MarketDataView: 市场数据
- StockQuoteCard: 股票行情卡片

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:22:43 +08:00
zdl
92019ca92d fix: 修复 Antd 和 React 废弃 API 警告
- AutoComplete/Select: dropdownStyle -> styles.popup.root
- AutoComplete/Select: popupClassName -> classNames.popup.root
- 移除 WebkitBackdropFilter(Chakra backdropFilter 自动处理)
- Table rowKey: 使用唯一标识符替代 index 参数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:15:09 +08:00
zdl
010ed9b5bf refactor(MarketDataView): 提取共享组件,简化 Panel 结构
- 新增 shared 目录,提取重复组件:
  - DarkGoldCard: 黑金卡片容器
  - DarkGoldBadge: 黑金徽章组件
  - EmptyState: 空状态组件
  - styles.ts: 共享样式定义
- 简化各 Panel 组件,移除重复代码
- 优化 index.tsx componentProps 传递
- 调整 hooks/services 数据获取逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:09:20 +08:00
zdl
afc6d16119 chore(StockQuoteCard): 删除未使用的组件
- 删除 CompanyInfo.tsx(未被引用)
- 删除 KeyMetrics.tsx(未被引用)
- 更新 index.ts 移除相关导出

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:06:08 +08:00
zdl
61e159f29b refactor(StockQuoteCard): 子组件提取与 DEEP_SPACE_THEME 统一
- PriceDisplay: 更新为 DEEP_SPACE_THEME,添加发光效果
- StockHeader: 更新为 DEEP_SPACE_THEME,金色边框和发光
- MetricRow: 新建指标行组件,支持普通和高亮模式
- 主组件从 321 行精简到 180 行(-44%)
- 统一使用提取的子组件,移除内联代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 13:03:21 +08:00
zdl
82290e8a63 docs(useCompanyData): 添加 isInWatchlist 派生逻辑注释
说明 localStorage 缓存机制确保大多数情况下立即显示正确状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 12:54:32 +08:00
zdl
029a61e42c fix(Company): 自选股状态同步到 Redux 全局状态
- useCompanyData 改用 Redux stockSlice 管理自选股状态
- isInWatchlist 从 Redux watchlist 中派生,确保全局同步
- toggleWatchlist 使用 Redux action,乐观更新 + localStorage 持久化
- 移除独立的 loadWatchlistStatus API 调用,复用 Redux 缓存

修复问题:Company 页面关注按钮与导航栏等其他组件状态不同步

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 11:39:50 +08:00
zdl
958222e75f refactor(StockQuoteCard): 组件拆分与 FUI 光效统一
- 新增 CardGlow 组件到 @components/FUI,支持多种颜色主题 (gold/cyan/purple)
- 拆分 StockQuoteCard 子组件:GlassSection、LoadingSkeleton
- 更新 KeyMetrics、MainForceInfo、SecondaryQuote 使用 DEEP_SPACE_THEME
- 主组件从 540 行精简到 321 行(减少 40%)
- 删除重复的 GlowDecorations,统一使用 FUI/CardGlow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 11:29:28 +08:00
zdl
5b7534f6a5 perf(CompanyHeader): 性能优化与代码重构
- 移除不必要的 PageTitle 子组件(纯静态内容,memo 无意义)
- 提取样式常量到 constants.ts,避免每次渲染重新创建对象
- 简化 SearchBox props,移除未使用的 stockCode
- 点击搜索图标支持发起搜索
- 移除未使用的 FUI_GLASS 导入
- 简化 CompanyHeaderProps 类型定义

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 11:08:01 +08:00
zdl
1730a59ca2 refactor(Company): 简化 CompanyHeader,添加详细代码注释
- CompanyHeader: 移除冗余的股票信息展示(已在 StockQuoteCard 中)
- index.tsx: 添加完整的 JSDoc 注释和架构说明
- types.ts: 简化 CompanyHeaderProps,移除不再需要的属性
- useStockQuoteData: 优化数据获取逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:58:49 +08:00
zdl
986ec05eb1 Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock
* 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react:
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
2025-12-19 10:16:07 +08:00
zdl
02cc3eadd9 feat: 新增 financialService 类型声明和 EChartsWrapper 组件
- financialService.d.ts: 为 JS 服务文件提供 TypeScript 类型声明
- EChartsWrapper.tsx: 按需引入的 ECharts 包装组件,减小打包体积

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:15:59 +08:00
zdl
51721ce9bf perf(Company): 优化渲染性能和 API 请求
- StockQuoteCard: 添加 memo 包装减少重渲染
- Company/index: componentProps 使用 useMemo 缓存
- useCompanyEvents: 页面浏览事件只触发一次,避免重复追踪
- useCompanyData: 自选股状态改用单股票查询接口,减少数据传输
- CompanyHeader: inputCode 状态下移到 SearchActions,减少父组件重渲染
- CompanyHeader: 移除重复环境光效果,由全局 AmbientGlow 统一处理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:14:07 +08:00
25b2c2af49 更新Company页面的UI为FUI风格 2025-12-18 22:06:22 +08:00
c7033481ee 更新Company页面的UI为FUI风格 2025-12-18 21:54:05 +08:00
d65376739b 更新Company页面的UI为FUI风格 2025-12-18 21:42:39 +08:00
52858006b7 更新Company页面的UI为FUI风格 2025-12-18 21:39:52 +08:00
7727fcfe15 更新Company页面的UI为FUI风格 2025-12-18 21:33:47 +08:00
20ad62d229 更新Company页面的UI为FUI风格 2025-12-18 21:22:20 +08:00
0bb47e1710 更新Company页面的UI为FUI风格 2025-12-18 21:10:11 +08:00
1fa85639f4 更新Company页面的UI为FUI风格 2025-12-18 20:12:32 +08:00
4ac9b30bfb 更新Company页面的UI为FUI风格 2025-12-18 20:06:51 +08:00
64fdb6e580 更新Company页面的UI为FUI风格 2025-12-18 19:41:04 +08:00
zdl
c979e775a5 perf(Company): 恢复 CompanyContent 的 memo 包装
- 将主内容区提取为独立的 memo 包装组件
- 避免父组件状态变化导致不必要的重渲染
- 提升页面性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:43:05 +08:00
zdl
2720946ccf fix(types): 修复 ECharts 类型导出和组件类型冲突
- echarts.ts: 将 EChartsOption 改为 EChartsCoreOption 的类型别名
- FuiCorners: 移除 extends BoxProps,position 重命名为 corner
- KLineChartModal/TimelineChartModal/ConcentrationCard: 使用导入的 EChartsOption
- LoadingState: 新增骨架屏 variant 支持
- FinancialPanorama: 使用骨架屏加载状态
- useFinancialData/financialService: 优化数据获取逻辑
- Company/index: 简化组件结构

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:42:19 +08:00
zdl
5331bc64b4 perf: 优化各 Tab 数据加载为按需请求
MarketDataView (股票行情):
- 初始只加载 summary + tradeData(2个接口)
- funding/bigDeal/unusual/pledge 数据在切换 Tab 时按需加载
- 新增 loadDataByType 方法支持懒加载

FinancialPanorama (财务全景):
- 初始只加载 stockInfo + metrics + comparison + mainBusiness(4个接口)
- 从9个接口优化到4个接口

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:32:14 +08:00
zdl
3953efc2ed refactor(theme): 统一黑金主题常量,减少硬编码
- theme/index.ts: 添加 COLORS, GLOW, GLASS 便捷常量
- theme/index.ts: 导出 glassCardStyle 可复用样式
- BusinessInfoPanel: 迁移到使用统一主题常量

迁移指南:
- import { COLORS, GLASS, glassCardStyle } from '@views/Company/theme'
- 替换 rgba(212, 175, 55, x) → COLORS.border / COLORS.borderHover
- 替换硬编码背景 → GLASS.bgDark / COLORS.bgGlass

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:29:42 +08:00
zdl
50d59fd2ad perf(DeepAnalysis): 优化初始加载,只请求 comprehensive 接口
- 移除初始加载时的 industryRank 请求
- 只加载默认 Tab(战略分析)需要的核心数据
- 其他数据按需懒加载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:27:57 +08:00
zdl
eaa65b2328 fix(SubTabContainer): 移除外层 Suspense,Tab 内容直接展示
- SubTabContainer 内部为每个 Tab 添加 Suspense fallback={null}
- 移除 Company/index.tsx 外层 Suspense 和 TabLoadingFallback
- 切换一级 Tab 时不再显示整体 loading,直接展示内容

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:23:16 +08:00
zdl
79572fcc98 style(BusinessInfoPanel): 优化工商信息模块 UI
- 使用玻璃态卡片布局(Glassmorphism)
- 添加图标增强视觉效果
- 信息行使用悬停效果
- 服务机构使用独立卡片展示
- 主营业务/经营范围两列布局
- 统一 FUI 黑金主题风格

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:16:42 +08:00
zdl
997724e0b1 fix(FinancialPanorama): 优化 loading 状态,Tabs 立即显示
- 移除 SubTabContainer 的 loading 条件渲染,Tabs 始终可见
- 各 Tab 组件内部处理 loading 状态,显示 Spinner
- 传递 loading 和 loadingTab 到 componentProps
- 修改 BalanceSheetTab、IncomeStatementTab、CashflowTab、
  FinancialMetricsTab、MetricsCategoryTab 支持 loading 属性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:15:30 +08:00
zdl
ec2270ca8e fix(mock): 修复股权集中度和实控人数据格式
- 移除 holding_ratio 除以 100 的错误转换
- 数据保持原始百分比格式(如 52.38 表示 52.38%)
- 修复饼图显示异常问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:14:42 +08:00
zdl
44ba2e24e8 fix(SubTabContainer): 保持 Tab 面板挂载状态,防止切换时状态丢失
- 添加 lazyBehavior="keepMounted" 属性
- 修复切换一级 Tab 后二级 Tab 状态被完全重置的问题
- 组件仍然懒加载,但首次渲染后保持挂载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:09:36 +08:00
zdl
8e679b56f4 style(StockQuoteCard): 优化布局和样式
- 数据区块改为三列布局:估值指标 | 市值股本 | 主力动态
- 流通市值、发行总股本、52周波动 放在同一列
- 区块标题高亮显示(金色 + 发光效果)
- 注释掉公司信息模块(成立日期、注册资本、所在地等)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:28 +08:00
zdl
ae397ac904 feat(mock): 完善 Mock 数据,修复 API 返回格式
- event.js: 修复 /api/events 返回格式,匹配 useEventData 期望的结构
- stock.js: 添加 /api/stock/:code/quote-detail handler(完整行情数据含买卖盘)
- stock.js: 添加 /api/flex-screen/quotes handler(指数行情)
- stock.js: 修复 /api/index/:code/kline 支持 minute 类型

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:21 +08:00
zdl
a5bc1e1ce3 fix: 优化错误处理,减少控制台噪音
- axiosConfig: 忽略 CanceledError 错误日志(组件卸载时的正常行为)
- socketService: 首次连接失败使用 warn 级别,后续重试使用 debug 级别
- useEventData: 添加防御性检查,防止 pagination 为 undefined 时崩溃

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:10 +08:00
zdl
2ce74b4331 style: 移除主 Tab 内容区的 padding
- Company/index.tsx: contentPadding 从 6 改为 0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:01:56 +08:00
zdl
7931abe89b style: 移除公司概览与股权结构之间的间距
- BasicInfoTab: 设置 contentPadding={0}

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:00:16 +08:00
zdl
9b8983869c style: 子 Tab 紧凑模式,移除多余边距
- SubTabContainer: 添加 compact 属性
  - 移除 TabList 的 mx/mb 外边距
  - 移除圆角和阴影
  - 减小垂直内边距
- BasicInfoTab: 启用 compact 模式,移除 Card 包裹

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 17:54:56 +08:00
zdl
4b3588e8de feat: 将 StockQuoteCard 提升到 Tab 容器上方 + 修复 TS 警告
功能变更:
- 将 StockQuoteCard 从 CompanyOverview 移至 Company/index.tsx
- 股票行情卡片现在在切换 Tab 时始终可见

TypeScript 警告修复:
- SubTabContainer: WebkitBackdropFilter 改用 sx 属性
- DetailTable: 重新定义 TableRowData 类型,支持 boolean 索引
- SubscriptionContentNew: 添加类型安全的 AGREEMENT_URLS 索引访问

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 17:25:21 +08:00
42091bc7e5 更新Company页面的UI为FUI风格 2025-12-18 16:41:17 +08:00
d25c77353a 更新Company页面的UI为FUI风格 2025-12-18 14:20:53 +08:00
f36e210fe8 更新Company页面的UI为FUI风格 2025-12-18 09:07:26 +08:00
63ac4271b7 更新Company页面的UI为FUI风格 2025-12-18 08:34:16 +08:00
87ddc79252 更新Company页面的UI为FUI风格 2025-12-18 08:23:04 +08:00
26548c7036 更新Company页面的UI为FUI风格 2025-12-18 07:26:10 +08:00
028869aa0c 更新Company页面的UI为FUI风格 2025-12-18 00:24:11 +08:00
9623b08183 更新Company页面的UI为FUI风格 2025-12-18 00:14:50 +08:00
3199e6764d 更新Company页面的UI为FUI风格 2025-12-18 00:05:55 +08:00
852438b17e 更新Company页面的UI为FUI风格 2025-12-17 23:54:38 +08:00
c589e629b0 更新Company页面的UI为FUI风格 2025-12-17 23:48:37 +08:00
a2f224d118 更新Company页面的UI为FUI风格 2025-12-17 23:38:46 +08:00
6cb2742cf6 更新Company页面的UI为FUI风格 2025-12-17 23:20:33 +08:00
8acae9c93c 更新Company页面的UI为FUI风格 2025-12-17 22:56:12 +08:00
983d2575b2 更新Company页面的UI为FUI风格 2025-12-17 22:40:27 +08:00
0214052965 更新Company页面的UI为FUI风格 2025-12-17 22:30:18 +08:00
3adff89995 更新Company页面的UI为FUI风格 2025-12-17 22:22:44 +08:00
0d150f7b26 更新Company页面的UI为FUI风格 2025-12-17 21:41:57 +08:00
067b720263 更新Company页面的UI为FUI风格 2025-12-17 21:11:34 +08:00
318a83434a 更新Company页面的UI为FUI风格 2025-12-17 20:54:00 +08:00
c393e31eec 更新Company页面的UI为FUI风格 2025-12-17 19:31:55 +08:00
854aadcbc7 更新Company页面的UI为FUI风格 2025-12-17 19:21:48 +08:00
7b5ac2ef15 更新Company页面的UI为FUI风格 2025-12-17 19:20:10 +08:00
7054124eaf 更新Company页面的UI为FUI风格 2025-12-17 19:08:06 +08:00
4eb8310038 更新Company页面的UI为FUI风格 2025-12-17 19:05:02 +08:00
9b8d7d1d96 Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock 2025-12-17 18:35:25 +08:00
2d5d3b3342 update pay ui 2025-12-17 18:35:15 +08:00
zdl
480d446217 Merge branch 'feature_2025/251209_stock_pref' into feature_bugfix/251217_stock
* feature_2025/251209_stock_pref:
  feat(性能监控): 补全 T0 标记 + PostHog 上报
  fix(MSW): Bytedesk 添加 mock 数据响应
  fix(NotificationContext): Mock 模式下跳过 Socket 连接
2025-12-17 18:34:12 +08:00
zdl
e02cbcd9b7 feat(性能监控): 补全 T0 标记 + PostHog 上报
- index.js: 添加 html-loaded 标记(T0 监控点)
- performanceMonitor.ts: 调用 reportPerformanceMetrics 上报到 PostHog
- 现在完整监控 T0-T5 全部阶段并上报性能指标

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 18:29:35 +08:00
dbd4cb39ec update pay ui 2025-12-17 18:27:56 +08:00
88db9158d6 update pay ui 2025-12-17 18:12:22 +08:00
542e1c6225 update pay ui 2025-12-17 17:45:42 +08:00
697c366e88 update pay ui 2025-12-17 17:29:08 +08:00
8def7f355b update pay ui 2025-12-17 17:22:49 +08:00
c1fcf6714e update pay ui 2025-12-17 17:08:02 +08:00
4bf42004b7 update pay ui 2025-12-17 17:02:10 +08:00
zdl
cb662c8a37 Merge branch 'feature_bugfix/251201_py_h5_ui' into feature_bugfix/251217_stock
* feature_bugfix/251201_py_h5_ui:
  update pay ui
  update pay ui
  update pay ui
  update pay ui
2025-12-17 16:55:48 +08:00
zdl
9bb9eab922 fix(MSW): Bytedesk 添加 mock 数据响应
- 未读消息数量返回 { count: 0 }
- 其他 API 返回通用成功响应
- 解决 mock 模式下 404 错误

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:41:45 +08:00
zdl
3d7b0045b7 fix(NotificationContext): Mock 模式下跳过 Socket 连接
- 添加 REACT_APP_ENABLE_MOCK 环境变量检查
- Mock 模式下直接 return,避免连接生产服务器失败的错误
- 消除开发环境控制台的 WebSocket 连接错误

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:34:44 +08:00
zdl
a3a82794ca Merge branch 'feature_2025/251209_stock_pref' into feature_bugfix/251217_stock
* feature_2025/251209_stock_pref: (133 commits)
  chore(StockQuoteCard): 删除未使用的 mockData.ts
  refactor(marketService): 移除 apiRequest 包装函数,统一使用 axios.get
  docs(Company): 添加 API 接口清单到 STRUCTURE.md
  refactor(Company): 提取共享的 useStockSearch Hook
  fix(hooks): 添加 AbortController 解决竞态条件问题
  fix(SubTabContainer): 修复 Tab 懒加载失效问题
  chore(CompanyOverview): 移除未使用的 CompanyOverviewData 类型定义
  fix(CompanyOverview): 修复 useBasicInfo 重复调用问题
  refactor(Company): fetch 请求迁移至 axios
  docs(Company): 更新 STRUCTURE.md 添加数据下沉优化记录
  refactor(StockQuoteCard): 数据下沉优化,Props 从 11 个精简为 4 个
  feat(StockQuoteCard): 新增内部数据获取 hooks
  fix(MarketDataView): 添加缺失的 VStack 导入
  fix(MarketDataView): loading 背景色改为深色与整体一致
  refactor(Company): 统一所有 Tab 的 loading 状态组件
  style(ForecastReport): 详细数据表格 UI 优化
  style(ForecastReport): 盈利预测图表优化
  fix(ValueChainCard): 视图切换按钮始终靠右显示
  refactor(CompanyOverview): 优化多个面板显示逻辑
  style(DetailTable): 简化布局,标题+表格无嵌套
  ...
2025-12-17 16:06:43 +08:00
zdl
ada9f6e778 chore(StockQuoteCard): 删除未使用的 mockData.ts
- mockStockQuoteData 未被任何地方引用
- 数据现在通过 useStockQuoteData hook 从 API 获取

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:01:49 +08:00
zdl
07aebbece5 refactor(marketService): 移除 apiRequest 包装函数,统一使用 axios.get
- getMarketSummary, getTradeData, getFundingData, getPledgeData, getRiseAnalysis 改为直接使用 axios.get
- 删除 apiRequest<T> 包装函数
- 代码风格与 getBigDealData, getUnusualData, getMinuteData 保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:00:08 +08:00
zdl
7a11800cba docs(Company): 添加 API 接口清单到 STRUCTURE.md
- 梳理 Company 模块共 27 个 API 接口(去重后)
- 分 6 大类:股票基础信息(8)、股东信息(4)、行情数据(8)、深度分析(5)、财务数据(1)、事件新闻(1)
- 标注每个接口的方法类型和调用位置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:42:55 +08:00
zdl
3b352be1a8 refactor(Company): 提取共享的 useStockSearch Hook
- 新增 useStockSearch.ts:统一股票模糊搜索逻辑
  - 支持按代码或名称搜索
  - 支持排除指定股票(用于对比场景)
  - 使用 useMemo 优化性能
- 重构 SearchBar.js:使用共享 Hook,减少 15 行代码
- 重构 CompareStockInput.tsx:使用共享 Hook,减少 20 行代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:34:36 +08:00
zdl
c49dee72eb fix(hooks): 添加 AbortController 解决竞态条件问题
在以下 Hook 中添加请求取消逻辑,防止快速切换股票时旧数据覆盖新数据:
- useBasicInfo
- useShareholderData
- useManagementData
- useBranchesData
- useAnnouncementsData
- useDisclosureData
- useStockQuoteData

修复前:stockCode 变化时,旧请求可能后返回,覆盖新数据
修复后:cleanup 时取消旧请求,确保数据一致性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:20:36 +08:00
zdl
7159e510a6 fix(SubTabContainer): 修复 Tab 懒加载失效问题
- 添加 visitedTabs 状态记录已访问的 Tab 索引
- Tab 切换时更新已访问集合
- TabPanels 中实现条件渲染:只渲染当前或已访问过的 Tab

修复前:tabs.map() 会创建所有组件实例,导致 Hook 立即执行
修复后:仅首次访问 Tab 时才渲染组件,真正实现懒加载

效果:初始加载从 N 个请求减少到 1 个请求

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 14:44:46 +08:00
zdl
385d452f5a chore(CompanyOverview): 移除未使用的 CompanyOverviewData 类型定义
useCompanyOverviewData hook 已在 axios 迁移中删除,
对应的类型定义也应清理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:46:16 +08:00
zdl
bdc823e122 fix(CompanyOverview): 修复 useBasicInfo 重复调用问题
- BusinessInfoPanel: 改为内部调用 useBasicInfo,自行获取数据
- BasicInfoTab: 移除 basicInfo prop 传递
- CompanyOverview: 移除顶层 useBasicInfo 调用
- types.ts: 补充 BasicInfo 工商信息字段类型定义

修复前:CompanyOverview 和各子组件重复请求 /api/stock/{code}/basic-info
修复后:仅 BusinessInfoPanel 在需要时请求一次

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:02:31 +08:00
zdl
c83d239219 refactor(Company): fetch 请求迁移至 axios
- DeepAnalysis: 4 个 fetch → axios
- DynamicTracking: 3 个 fetch → axios (NewsPanel, ForecastPanel)
- MarketDataView/services: 4 个 fetch → axios
- CompanyOverview/hooks: 9 个 fetch → axios (6 个文件)
- StockQuoteCard/hooks: 1 个 fetch → axios
- ValueChainNodeCard: 1 个 fetch → axios

清理:
- 删除未使用的 useCompanyOverviewData.ts
- 移除所有 getApiBase/API_BASE_URL 引用

总计: 22 个 fetch 调用迁移, 复用项目已有的 axios 拦截器配置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:54:32 +08:00
zdl
c4900bd280 docs(Company): 更新 STRUCTURE.md 添加数据下沉优化记录
- 更新目录结构:新增 StockQuoteCard/hooks/
- 更新 hooks 目录说明:标注 useStockQuote.js 已下沉
- 更新入口文件说明:列出已移除的模块
- 新增 2025-12-17 重构记录:StockQuoteCard 数据下沉优化详情

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:25 +08:00
zdl
7736212235 refactor(StockQuoteCard): 数据下沉优化,Props 从 11 个精简为 4 个
- StockQuoteCard 使用内部 hooks 获取行情数据、基本信息和对比数据
- 更新 types.ts,简化 Props 接口
- Company/index.js 移除已下沉的数据获取逻辑(~40 行)
- 删除 Company/hooks/useStockQuote.js(已移至组件内部)

优化收益:
- Props 数量: 11 → 4 (-64%)
- Company/index.js: ~172 → ~105 行 (-39%)
- 组件可独立复用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:07 +08:00
zdl
348d8a0ec3 feat(StockQuoteCard): 新增内部数据获取 hooks
- useStockQuoteData: 合并行情数据和基本信息获取
- useStockCompare: 股票对比逻辑封装
- 为数据下沉优化做准备

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:12:14 +08:00
zdl
5a0d6e1569 fix(MarketDataView): 添加缺失的 VStack 导入
- 修复 TypeScript 类型检查错误

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:11:59 +08:00
zdl
bc2b6ae41c fix(MarketDataView): loading 背景色改为深色与整体一致
- 移除白色 ThemedCard 包装
- 使用 gray.900 背景 + 金色边框

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:34:36 +08:00
zdl
ac7e627b2d refactor(Company): 统一所有 Tab 的 loading 状态组件
- 创建共享的 LoadingState 组件(黑金主题)
- DeepAnalysisTab: 使用统一 LoadingState 替换蓝色 Spinner
- FinancialPanorama: 使用 LoadingState 替换 Skeleton
- MarketDataView: 使用 LoadingState 替换自定义 Spinner
- ForecastReport: 使用 LoadingState 替换 Skeleton 骨架屏

所有一级 Tab 现在使用一致的金色 Spinner + 加载提示文案

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:31:38 +08:00
zdl
21e83ac1bc style(ForecastReport): 详细数据表格 UI 优化
- 斑马纹(奇数行浅色背景)
- 等宽字体(SF Mono/Monaco/Menlo)
- 重要指标行高亮(归母净利润、ROE、EPS、营业总收入)
- 预测列区分样式(斜体+浅金背景+分隔线)
- 负数红色/正增长绿色显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:38 +08:00
zdl
e2dd9e2648 style(ForecastReport): 盈利预测图表优化
- 预测年份 X 轴金色高亮,预测区域添加背景标记
- Y 轴颜色与对应数据系列匹配
- PEG 改用青色点划线+菱形符号,增加 PEG=1 参考线
- EPS 图添加行业平均参考线
- Tooltip 显示预测标签,智能避让

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:30 +08:00
zdl
f2463922f3 fix(ValueChainCard): 视图切换按钮始终靠右显示
使用 ml="auto" 确保切换按钮在流向关系视图时保持右侧位置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:53:08 +08:00
zdl
9aaad00f87 refactor(CompanyOverview): 优化多个面板显示逻辑
- ValueChainCard: 流向关系视图时隐藏左侧导航选项
- AnnouncementsPanel: 移除重复的"最新公告"标题
- DisclosureSchedulePanel: 移除重复的"财报披露日程"标题
- CompetitiveAnalysisCard: 恢复竞争对手标签和雷达图显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:51:27 +08:00
zdl
024126025d style(DetailTable): 简化布局,标题+表格无嵌套
- 移除外层卡片包装,直接显示标题和表格
- 使用 Chakra Text 作为标题
- 表格背景改为透明

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:43:05 +08:00
zdl
e2f9f3278f refactor(ForecastReport): 3列布局 + 移除标题
- 移除盈利预测报表标题和刷新按钮
- 3个图表改为3列等宽布局
- 统一图表高度使用 CHART_HEIGHT
- 简化标题文字

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:40:34 +08:00
zdl
2d03c88f43 style(DynamicTracking): 应用黑金主题
- NewsEventsTab: 添加黑金主题配色系统
- ForecastPanel: 业绩预告面板黑金样式
- NewsPanel: 切换 blackGold 主题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:29 +08:00
zdl
515b538c84 refactor(ForecastReport): 合并营收/利润趋势与增长率图表
- 新增 IncomeProfitGrowthChart 合并组件
- 柱状图显示营业收入(左Y轴)
- 折线图显示净利润(左Y轴,渐变填充)
- 虚线显示增长率(右Y轴,红涨绿跌)
- 布局调整:合并图表独占一行,EPS/PE-PEG 两列

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:24 +08:00
zdl
b52b54347d fix(mock): 修复事件数据和 API 返回格式
- events.js: 增强搜索支持股票名称/代码,修复字段名
- event.js: 返回结构调整为 { data, pagination }

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:20 +08:00
zdl
4954373b5b style(ComparisonAnalysis): 应用黑金主题样式
- 图表配置:金色标题、深色 tooltip、金色坐标轴
- 净利润折线改为金色渐变填充
- 营收柱状图首个柱子使用金色
- 组件容器:透明背景 + 金色边框
- 移除外部重复标题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:35:14 +08:00
zdl
66cd6c3a29 fix(mock): 修复 periodComparison 数据结构
- 将 periodComparison 从对象格式改为数组格式
- 匹配 ComparisonAnalysis 组件期望的数据结构
- 修复"盈利与利润趋势"图表无法显示的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:33:07 +08:00
zdl
ba99f55b16 refactor(ForecastReport): 迁移至 TypeScript 2025-12-16 20:28:58 +08:00
zdl
2f69f83d16 feat(mock): 添加业绩预告 mock 数据
- 新增 /api/stock/:stockCode/forecast handler
 - 支持动态跟踪下的业绩预告面板
2025-12-16 20:27:43 +08:00
zdl
3bd48e1ddd refactor(StockQuoteCard): 拆分为原子组件
- 新增 theme.ts 黑金主题常量
  - 新增 formatters.ts 格式化工具函数
  - 拆分 PriceDisplay/SecondaryQuote/KeyMetrics/MainForceInfo/CompanyInfo/StockHeader
  - 主组件从 414 行简化为 150 行
  - 提高可维护性和复用性
2025-12-16 20:24:01 +08:00
zdl
84914b3cca fix(FinancialPanorama): 恢复盈利与利润趋势图表
- 重新引入 ComparisonAnalysis 组件
- 在财务全景面板下方显示营收与利润趋势柱状图
- 修复之前重构时遗漏的功能模块

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:22:14 +08:00
zdl
da455946a3 style(MainBusinessAnalysis): 优化主营业务模块 UI
- 饼图配色改为黑金主题(金色系渐变)
- 修复表格固定列 hover 时背景色为白色的问题
- 统一表格单元格背景色为深色 #1A202C

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:20:15 +08:00
zdl
e734319ec4 refactor(FinancialPanorama): 使用 FinancialOverviewPanel 替换原头部组件
- 移除 StockInfoHeader 和 KeyMetricsOverview
- 使用新的三模块面板组件
- ROE 去重,布局统一

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:19 +08:00
zdl
faf2446203 feat(FinancialPanorama): 新增 FinancialOverviewPanel 三模块布局
- 复用 MetricCard 组件构建三列布局
- 成长能力:利润增长、营收增长、预增标签
- 盈利与回报:ROE、净利率、毛利率
- 风险与运营:资产负债率、流动比率、研发费用率

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:08 +08:00
zdl
83b24b6d54 style(MainBusinessAnalysis): 优化历史对比表格布局
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:15:57 +08:00
zdl
ab7164681a feat(StockQuoteCard): 新增每股收益(EPS)显示
- Mock 数据添加 eps、pb、主力动态等指标
  - StockQuoteCard 显示 EPS 数据
  - useStockQuote 支持 eps 字段转换
  - StockInfoHeader 移除重复的 EPS 显示
2025-12-16 20:08:35 +08:00
zdl
bc6d370f55 refactor(FinancialPanorama): 重构为 7+3 Tab 架构
- 财务指标拆分为 7 个分类 Tab(盈利/每股/成长/运营/偿债/费用/现金流)
- 保留 3 大报表 Tab(资产负债表/利润表/现金流量表)
- 新增 KeyMetricsOverview 关键指标速览组件
- 新增 FinancialTable 通用表格组件
- Hook 支持按 Tab 独立刷新数据
- PeriodSelector 整合到 SubTabContainer 右侧
- 删除废弃的 OverviewTab/MainBusinessTab

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:30 +08:00
zdl
42215b2d59 refactor(mocks): 调整主营业务数据结构为多期分类格式
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:06 +08:00
zdl
c34aa37731 feat(SubTabContainer): 新增 rightElement prop 支持自定义右侧内容
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:00 +08:00
zdl
2eb2a22495 feat(DeepAnalysis): 竞争地位分析增加行业排名弹窗
- CompetitiveAnalysisCard 新增 Modal 弹窗展示行业排名详情
  - 点击 Badge 或查看详情按钮可打开弹窗
  - 弹窗采用黑金主题样式
  - StrategyTab 移除独立的 IndustryRankingView 展示
2025-12-16 16:33:45 +08:00
zdl
6a4c475d3a refactor(FinancialPanorama): 重构为 SubTabContainer 二级导航
- 主组件从 Chakra Tabs 迁移到 SubTabContainer
  - 新增 PeriodSelector 时间选择器组件
  - IndustryRankingView 增加深色主题支持
  - 拆分出 6 个独立 Tab 组件到 tabs/ 目录
  - 类型定义优化,props 改为可选
2025-12-16 16:33:25 +08:00
zdl
e08b9d2104 refactor(DynamicTracking): 拆分组件
- 新增 ForecastPanel: 业绩预告面板组件
 - 新增 NewsPanel: 新闻面板组件
 - 组件模块化重构
2025-12-16 16:22:56 +08:00
zdl
3f1f438440 feat(DeepAnalysis): 增强策略Tab功能
- 新增策略相关类型定义
 - StrategyTab 功能增强
 - 调整组件结构
2025-12-16 16:22:39 +08:00
zdl
24720dbba0 fix(mocks): 优化 financial.js Mock 数据 2025-12-16 16:22:24 +08:00
zdl
7877c41e9c feat(Company): 集成股票对比功能
- 新增 currentStockInfo/compareStockInfo 状态管理
 - 新增 handleCompare 处理对比数据加载
 - StockQuoteCard 传入对比相关 props
2025-12-16 16:15:52 +08:00
zdl
b25d48e167 feat(StockQuoteCard): 新增股票对比功能
- 新增 CompareStockInput: 股票搜索输入组件
 - 新增 StockCompareModal: 股票对比弹窗
 - 更新类型定义支持对比功能

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

 Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 16:15:36 +08:00
zdl
804de885e1 feat(DynamicTracking): 新增业绩预告Tab
- 新增 forecast Tab(从 FinancialPanorama 迁移)
 - 新增 loadForecast 数据加载逻辑
 - 新增业绩预告列表展示

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

 Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 16:13:49 +08:00
zdl
6738a09e3a fix(FinancialPanorama): 修复Mock数据结构 + 移除业绩预告Tab
- financial.js: 修复字段名 code→stock_code, name→stock_name
 - financial.js: 财务报表改为嵌套结构匹配类型定义
 - 移除业绩预告Tab(迁移至DynamicTracking)
2025-12-16 16:13:25 +08:00
zdl
67340e9b82 feat(MarketDataView): K线图优化 - 按需刷新 + 黑金主题
- useMarketData: 新增 refreshTradeData,切换时间范围只刷新K线数据
 - chartOptions: 新增黑金主题配置函数
 - 优化 useEffect,避免切换周期时全量刷新
2025-12-16 16:12:39 +08:00
zdl
00f2937a34 refactor(MarketDataView): 使用通用 SubTabContainer 简化代码
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:29:05 +08:00
zdl
91ed649220 refactor(MarketDataView): 优化图表配置和K线模块
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:28:59 +08:00
zdl
391955f88c style(Panels): 应用黑金主题样式 2025-12-16 15:24:13 +08:00
zdl
59f4b1cdb9 refactor(useMarketData): 优化数据获取逻辑 2025-12-16 15:24:00 +08:00
zdl
3d6d01964d feat(MarketDataView): 新增图表配置工具函数 2025-12-16 15:23:49 +08:00
zdl
3f3e13bddd feat(KLineModule): 添加日K时间范围选择器 2025-12-16 15:20:06 +08:00
zdl
d27cf5b7d8 style(StockSummaryCard): 优化黑金主题原子组件样式 2025-12-16 15:19:40 +08:00
zdl
03bc2d681b pref: FundingPanel 黑金主题改造 融资融券面板 2025-12-16 15:11:52 +08:00
zdl
1022fa4077 refactor(KLineModule): 黑金主题 + 精简组件结构
- KLineModule 应用黑金主题(渐变背景、金色按钮、金色图标)
- 删除 TradeTable、MinuteStats、TradeAnalysis 组件
- 删除 atoms 目录,EmptyState 内联到 KLineModule
- 更新 types.ts 移除 TradeTableProps
- 更新导出文件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:03:50 +08:00
zdl
406b951e53 refactor(TradeDataPanel): 合并 KLineChart 和 MinuteKLineSection 为 KLineModule
- 新增 KLineModule 组件,整合日K线和分钟K线功能
- 右上角 ButtonGroup 切换「日K」/「分钟」模式
- 刷新按钮置于切换按钮组前方
- 切换到分钟模式时自动加载数据
- 删除旧的 KLineChart.tsx 和 MinuteKLineSection.tsx
- 更新 panels/index.ts 导出
- 更新 types.ts,合并类型定义为 KLineModuleProps

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:52:06 +08:00
zdl
7f392619e7 refactor(TradeDataPanel): 原子设计模式拆分重构
- 将 TradeDataPanel.tsx (382行) 拆分为 8 个 TypeScript 文件
- 创建 3 个原子组件: MinuteStats、TradeAnalysis、EmptyState
- 创建 3 个业务组件: KLineChart、MinuteKLineSection、TradeTable
- 主入口组件精简至 ~50 行,降低 87%
- 更新 panels/index.ts 导出子组件
- 更新 STRUCTURE.md 文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:34:45 +08:00
zdl
09ca7265d7 refactor(StockSummaryCard): 黑金主题 4 列布局重构
- 布局从 1+3 改为 4 列横向排列(股票信息/交易热度/估值安全/情绪风险)
- 新增 darkGoldTheme 黑金主题配置
- 采用原子设计模式拆分:5 个原子组件 + 2 个业务组件
- 原子组件:DarkGoldCard、CardTitle、MetricValue、PriceDisplay、StatusTag
- 业务组件:StockHeaderCard、MetricCard
- 提取状态计算工具到 utils.ts
- types.ts: theme 参数改为可选

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:01:42 +08:00
zdl
276b280cb9 docs: 更新 STRUCTURE.md 和 mock 数据
- STRUCTURE.md 添加 MarketDataView Panel 拆分记录
- 更新目录结构说明,包含 panels/ 子目录
- 更新 company.js 和 market.js mock 数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:03 +08:00
zdl
adfc0bd478 refactor(MarketDataView): 使用 Panel 组件重构主组件
- 主组件从 1049 行精简至 285 行(减少 73%)
- 添加 panels/index.ts 统一导出
- Tab 容器和状态管理保留在主组件
- 各面板内容拆分到独立组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:08:40 +08:00
zdl
85a857dc19 feat(MarketDataView): 新增 5 个 Panel 组件
- TradeDataPanel: 交易数据面板(K线图、分钟图、表格)
- FundingPanel: 融资融券面板
- BigDealPanel: 大宗交易面板
- UnusualPanel: 龙虎榜面板
- PledgePanel: 股权质押面板

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:07:58 +08:00
zdl
b89837d22e feat(DeepAnalysis): 实现 Tab 懒加载,按需请求数据
- DeepAnalysis/index.js: 重构为懒加载模式
  - 添加 TAB_API_MAP 映射 Tab 与接口关系
  - 战略分析/业务结构共享 comprehensive-analysis 接口
  - 产业链/发展历程按需加载对应接口
  - 使用 loadedApisRef 缓存已加载状态,避免重复请求
  - 各接口独立 loading 状态管理
  - 添加 stockCode 竞态条件保护

- DeepAnalysisTab/index.tsx: 支持受控模式
  - 新增 activeTab/onTabChange props
  - loading 状态下保持 Tab 导航可切换

- types.ts: 新增 DeepAnalysisTabKey 类型和相关 props

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:20:37 +08:00
zdl
942dd16800 docs(Company): 更新 STRUCTURE.md 添加 FinancialPanorama 模块结构
- 添加 FinancialPanorama 完整目录结构说明
- 记录18个文件的职责和功能
- 更新模块化重构后的架构文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:02:21 +08:00
zdl
35e3b66684 refactor(FinancialPanorama): 重构主组件为模块化结构
从 2,150 行单文件重构为模块化 TypeScript 组件:
- 使用 useFinancialData Hook 管理数据加载
- 组合9个子组件渲染9个Tab面板
- 保留指标图表弹窗功能
- 保留期数选择器功能
- 删除旧的 index.js(2,150行)
- 新增 index.tsx(454行,精简79%)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:02:05 +08:00
zdl
b9ea08e601 refactor(FinancialPanorama): 添加9个子组件
财务报表组件:
- BalanceSheetTable: 资产负债表(可折叠分类)
- IncomeStatementTable: 利润表(支持负向指标反色)
- CashflowTable: 现金流量表
- FinancialMetricsTable: 财务指标(7分类切换 + 关键指标速览)

业务分析组件:
- StockInfoHeader: 股票信息头部
- MainBusinessAnalysis: 主营业务分析(饼图 + 表格)
- IndustryRankingView: 行业排名展示
- StockComparison: 股票对比(多维度)
- ComparisonAnalysis: 综合对比分析(双轴图)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:47 +08:00
zdl
d9106bf9f7 refactor(FinancialPanorama): 添加数据加载 Hook
useFinancialData Hook 功能:
- 9个财务API并行加载(Promise.all)
- 股票信息、资产负债表、利润表、现金流量表
- 财务指标、主营业务、业绩预告
- 行业排名、期间对比
- 支持期数选择(4/8/12/16期)
- 自动响应 stockCode 变化重新加载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:26 +08:00
zdl
fb42ef566b refactor(FinancialPanorama): 添加工具函数模块
计算工具 (calculations.ts):
- calculateYoYChange: 同比变化率计算
- getCellBackground: 单元格背景色(红涨绿跌)
- getValueByPath: 嵌套路径取值
- isNegativeIndicator: 负向指标判断

图表配置 (chartOptions.ts):
- getMetricChartOption: 指标趋势柱状图
- getComparisonChartOption: 营收利润双轴图
- getMainBusinessPieOption: 主营业务饼图
- getCompareBarChartOption: 股票对比柱状图

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:09 +08:00
zdl
a424b3338d refactor(FinancialPanorama): 添加常量配置模块
- 颜色配置:中国市场红涨绿跌
- 资产负债表指标:7个分类(流动/非流动资产、负债、权益)
- 利润表指标:6个分类(营收、成本、其他收益、利润、EPS、综合收益)
- 现金流量表指标:8个核心指标
- 财务指标分类:7大类(盈利、每股、成长、运营、偿债、费用、现金流)
- 行业排名和对比指标配置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:00:51 +08:00
zdl
9e6e3ae322 refactor(FinancialPanorama): 添加 TypeScript 类型定义
- 定义基础类型:StockInfo、财务报表数据结构
- 定义业务类型:主营业务、行业排名、业绩预告
- 定义组件 Props 类型:9个子组件的 Props 接口
- 定义指标配置类型:MetricConfig、MetricSectionConfig

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:00:34 +08:00
zdl
e92cc09e06 style: DeepAnalysisTab 黑金主题样式优化
- ProcessNavigation: Tab 未选中状态字体白色,数量Badge与边框颜色统一(gray.600)
- KeyFactorCard: 适配黑金主题(cardBg #252D3A, 文字颜色调整)
- KeyFactorsCard: 黑金主题重构,移除免责声明组件
- TimelineCard: 黑金主题重构,移除免责声明组件
- ValueChainCard: 调整 CardHeader 和 CardBody padding
- ValueChainFilterBar: 暂时注释筛选下拉框

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 14:25:48 +08:00
zdl
23112db115 refactor(ValueChainCard): 重构产业链分析卡片布局
- 新增 ProcessNavigation 流程导航组件(上游→核心→下游+副标题)
- 新增 ValueChainFilterBar 筛选栏组件(类型/重要度/视图Tab切换)
- 重构布局为左右分栏:左侧流程导航,右侧筛选+视图切换
- 移除 DisclaimerBox 免责声明
- ValueChainNodeCard 适配黑金主题
- 移除卡片内部左右边距

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 14:04:04 +08:00
zdl
7c7c70c4d9 style: 移除 Tab 导航和卡片内部左右 padding
- TabNavigation/SubTabContainer: 移除左侧 padding (pl=0)
- BusinessStructureCard/BusinessSegmentsCard: 移除 CardBody 左右 padding
- BusinessTreeItem: 黑金主题样式优化

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 13:13:34 +08:00
zdl
e049429b09 perf: Tab 容器组件优化
- TabPanelContainer: Loading 颜色改为金色 #D4AF37,与黑金主题一致
- SubTabContainer: 添加 memo 和 displayName
- 子 Tab 组件: StrategyTab/BusinessTab/ValueChainTab/DevelopmentTab 添加 memo 和 displayName
- TabContainer: 移除未使用的 showDivider 参数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 12:02:15 +08:00
zdl
b8cd520014 refactor: 抽取通用 Tab 容器组件,重构 BasicInfoTab 和 DeepAnalysisTab
新增组件:
- TabPanelContainer: 三级容器,统一 loading 状态 + VStack 布局 + 免责声明
- SubTabContainer: 二级导航容器,支持黑金/默认主题预设

重构:
- BasicInfoTab: 使用 SubTabContainer 替代原有 Tabs 实现
- DeepAnalysisTab: 拆分为 4 个子 Tab(战略分析/业务结构/产业链/发展历程)
- TabContainer: 样式调整,与 SubTabContainer 保持一致

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 11:55:50 +08:00
zdl
96fe919164 feat: 竞争优势内容调整 2025-12-12 11:01:22 +08:00
zdl
4672a24353 refactor: 抽取 TabPanelContainer 通用容器组件
- 新增 TabPanelContainer 组件,统一处理 loading 状态和 VStack 布局
- ShareholderPanel 使用 TabPanelContainer 替代原有 loading 判断和 VStack
- ManagementPanel 使用 TabPanelContainer 替代原有 loading 判断和 VStack
- 组件使用 React.memo 优化渲染性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 10:58:25 +08:00
zdl
26bc5fece0 style(CompetitiveAnalysisCard): 移除卡片边框
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 10:12:09 +08:00
zdl
1c35ea24cd chore(DeepAnalysisTab): 更新类型定义和组件引用
- types.ts: 扩展类型定义支持新组件结构
- index.tsx: 更新组件 props 传递

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:17 +08:00
zdl
d76b0d32d6 perf(CompetitiveAnalysisCard): 渲染优化与黑金 UI
- 渲染优化: React.memo, useMemo, 样式常量提取
- 子组件拆分: CompetitorTags, ScoreSection, AdvantagesSection
- 黑金 UI: 金色边框、金色标题、白色内容、深色雷达图主题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:10 +08:00
zdl
eb093a5189 perf(StrategyAnalysisCard): 渲染优化与黑金 UI
- 渲染优化: React.memo, useMemo, 样式常量提取
- 子组件拆分: EmptyState, ContentItem
- 黑金 UI: 金色标题、白色内容文字、空状态金色虚线边框

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:03 +08:00
zdl
2c0b06e6a0 refactor(CorePositioningCard): 模块化拆分与黑金 UI 优化
- 拆分为独立目录结构: atoms/, theme.ts, index.tsx
- 提取子组件: HighlightCard, ModelBlock, SectionHeader
- 应用黑金风格: 金色边框、透明背景、金色标题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:48:56 +08:00
zdl
b3fb472c66 feat(mock): 更新深度分析 mock 数据
- 核心定位: 更新一句话定位、投资亮点、商业模式
- 战略分析: 添加战略方向和战略举措数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:48:49 +08:00
zdl
6797f54b6c feat: 战略分析Ui调整 2025-12-11 17:37:24 +08:00
zdl
a47e0feed8 refactor(TabContainer): 抽取通用 Tab 容器组件
- 新增 src/components/TabContainer/ 通用组件
  - 支持受控/非受控模式
  - 支持多种主题预设(blackGold、default、dark、light)
  - 支持自定义主题颜色和样式配置
  - 使用 TypeScript 实现,类型完整
- 重构 CompanyTabs 使用通用 TabContainer
- 删除 CompanyTabs/TabNavigation.js(逻辑迁移到通用组件)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:59:17 +08:00
zdl
13fa91a998 style(DeepAnalysisTab): 优化免责声明样式并更新 mock 数据
- DisclaimerBox: 简化为单行灰色文本,移除警告框样式
- Mock 数据: 更新核心定位、投资亮点、商业模式、战略分析内容
- 调整卡片顺序: 战略分析和业务板块上移

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 14:47:17 +08:00
zdl
fba7a7ee96 docs: 更新 Company 模块目录结构文档
- 添加 DeepAnalysisTab 模块化重构记录(2025-12-11)
- 更新目录结构中 DeepAnalysisTab.js → DeepAnalysisTab/
- 添加组件依赖关系图
- 添加工具函数位置表
- 添加优化效果对比

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:59:12 +08:00
zdl
32a73efb55 refactor(DeepAnalysisTab): 模块化拆分为 21 个 TypeScript 文件
将 1,796 行单文件拆分为原子设计模式结构:

**atoms/** - 原子组件
- DisclaimerBox: 免责声明警告框
- ScoreBar: 评分进度条
- BusinessTreeItem: 业务树形项
- KeyFactorCard: 关键因素卡片

**components/** - Card 容器组件
- CorePositioningCard: 核心定位
- CompetitiveAnalysisCard: 竞争地位分析(含雷达图)
- BusinessStructureCard: 业务结构
- ValueChainCard: 产业链分析
- KeyFactorsCard: 关键因素
- TimelineCard: 发展时间线
- BusinessSegmentsCard: 业务板块详情
- StrategyAnalysisCard: 战略分析

**organisms/** - 复杂组件
- ValueChainNodeCard: 产业链节点(含 RelatedCompaniesModal)
- TimelineComponent: 时间线(含 EventDetailModal)

**utils/**
- chartOptions.ts: ECharts 图表配置

优化效果:主文件从 1,796 行减少到 117 行(-93%)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:59:05 +08:00
zdl
7819b4f8a2 feat(utils): 添加深度分析格式化工具函数
- formatCurrency: 货币格式化(支持亿/万单位)
- formatBusinessRevenue: 营收格式化(智能单位转换)
- formatPercentage: 百分比格式化

从 DeepAnalysisTab 提取合并到全局工具库

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:58:52 +08:00
zdl
6f74c1c1de style: BranchesPanel 组件调整为黑金风格
- 卡片使用深色渐变背景,金色边框 + hover 发光效果
- 顶部添加金色渐变装饰线
- 状态徽章改为黑金风格(存续金色/非存续红色)
- 标题区域添加金色背景图标
- 信息项提取为 InfoItem 组件,优化布局
- 空状态使用金色圆形背景装饰

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:29:02 +08:00
zdl
3fed9d2d65 feat: 调整公众号配置 2025-12-10 19:59:37 +08:00
zdl
514917c0eb fix: 添加mock数据 2025-12-10 19:57:21 +08:00
zdl
6ce913d79b refactor: 整合 CompanyHeaderCard 到 StockQuoteCard,优化布局对齐
- 将公司基本信息整合到 StockQuoteCard 内部
- 采用 1:2 Flex 布局确保上下竖线对齐
- 删除废弃的 CompanyHeaderCard 组件
- 清理 types.ts 中的 CompanyHeaderCardProps

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 19:54:51 +08:00
zdl
6d5594556b refactor: ManagementPanel 组件拆分重构
- 创建 management/ 子目录,模块化管理
- 拆分为 5 个 TypeScript 文件:types.ts、ManagementPanel.tsx、CategorySection.tsx、ManagementCard.tsx、index.ts
- 添加 useMemo 缓存分类计算结果
- 使用 React.memo 优化 ManagementCard 和 CategorySection
- 添加完整的 TypeScript 类型定义,消除 any
- 更新 STRUCTURE.md 同步目录结构

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 19:28:05 +08:00
zdl
c32091e83e feat: 公司基础信息展示字段调整,公司概览调整为公司档案 2025-12-10 19:04:43 +08:00
zdl
2994de98c2 refactor: 财报披露日程独立为动态跟踪第三个 Tab
- 新建 DisclosureSchedulePanel 组件,独立展示财报披露日程
- 简化 AnnouncementsPanel,移除财报披露日程部分
- DynamicTracking 新增第三个 Tab:财报披露日程
- 更新 mock 数据字段名匹配组件需求

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 18:55:30 +08:00
zdl
c237a4dc0c fix: 调整UI 2025-12-10 18:18:05 +08:00
zdl
395dc27fe2 refactor: ShareholderPanel 拆分为子组件 + 黑金主题优化
- 新增 ActualControlCard 实际控制人卡片组件
- 新增 ConcentrationCard 股权集中度卡片(含 ECharts 饼图)
- 新增 ShareholdersTable 合并表格(支持十大股东/十大流通股东)
- Mock 数据优化:股东名称改为真实格式
- Handler 修复:数组格式处理 + holding_ratio 百分比转换
- UI: 黑金主题统一、表格 hover 金色半透明

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 18:00:00 +08:00
zdl
3abee6b907 docs: 更新 STRUCTURE.md 目录结构说明
- 添加 BasicInfoTab/ 目录结构详情
- 补充各子组件功能注释:
  - LoadingState: 加载状态组件
  - ShareholderPanel: 股权结构面板
  - ManagementPanel: 管理团队面板
  - AnnouncementsPanel: 公告信息面板
  - BranchesPanel: 分支机构面板
  - BusinessInfoPanel: 工商信息面板

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:29:34 +08:00
zdl
d86cef9f79 fix: 修复股权结构 Mock 数据格式
- actualControl 改为数组格式(支持多个实控人)
- concentration 改为数组格式(按季度分组,含 stat_item)
- topShareholders 添加 shareholder_rank、end_date、share_nature 字段
- topCirculationShareholders 添加 shareholder_rank、end_date 字段
- 字段名与 ShareholderPanel 组件期望格式统一

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:29:18 +08:00
zdl
9aaf4400c1 refactor: BasicInfoTab 拆分为 TypeScript 模块化组件
- 删除旧的 BasicInfoTab.js (~1000行)
- 新建 BasicInfoTab/ 目录,拆分为 10 个 TypeScript 文件:
  - index.tsx: 主组件(可配置 Tab)
  - config.ts: Tab 配置 + 黑金主题
  - utils.ts: 格式化工具函数
  - components/: 5 个面板组件 + LoadingState
- 主组件支持 enabledTabs、defaultTabIndex、onTabChange
- 应用黑金主题,支持懒加载 (isLazy)
- 更新 types.ts 添加 ActualControl、Concentration 等类型字段

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:28:54 +08:00
zdl
1cd8a2d7e9 fix: 颜色调整 2025-12-10 15:56:08 +08:00
zdl
af3cdc24b1 style: CompanyHeaderCard 黑金主题三区块布局重构
- 布局调整:从两栏(8:4)改为垂直三区块(身份分类 | 关键属性 | 公司介绍)
- 黑金主题:卡片背景 gray.800,金色强调色 #D4AF37
- 移除字段:法定代表人、董事长、总经理、邮箱、电话
- 保留字段:公司名称、代码、行业分类、成立日期、注册资本、所在地、官网、公司介绍
- CompanyTabs: TabPanel 去掉左右边距

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:53:50 +08:00
zdl
bfb6ef63d0 refactor: MarketDataView TypeScript 重构 - 2060 行拆分为 12 个模块
- 将原 index.js (2060 行) 重构为 TypeScript 模块化架构
- 新增 types.ts: 383 行类型定义 (Theme, TradeDayData, MinuteData 等)
- 新增 services/marketService.ts: API 服务层封装
- 新增 hooks/useMarketData.ts: 数据获取 Hook
- 新增 utils/formatUtils.ts: 格式化工具函数
- 新增 utils/chartOptions.ts: ECharts 图表配置生成器 (698 行)
- 新增 components/: ThemedCard, MarkdownRenderer, StockSummaryCard, AnalysisModal
- 添加 Company/STRUCTURE.md 目录结构文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:14:23 +08:00
zdl
722d038b56 Merge branch 'feature_bugfix/251201_py_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_py_h5_ui:
  feat: Company 页面搜索框添加股票模糊搜索功能
  update pay ui
  update pay ui
  fix: 个股中心bug修复
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  feat: 替换公众号文件
  update pay ui
2025-12-10 14:30:25 +08:00
zdl
5f6e4387e5 perf: CompanyOverview 内层 Tab 懒加载优化
- 将 useCompanyOverviewData(9个API)拆分为独立 Hooks:
  - useBasicInfo: 基本信息(首屏唯一加载)
  - useShareholderData: 股东信息(4个API)
  - useManagementData: 管理层信息
  - useAnnouncementsData: 公告数据
  - useBranchesData: 分支机构
  - useDisclosureData: 披露日程
- BasicInfoTab 使用子组件实现真正的懒加载:
  - ShareholderTabPanel、ManagementTabPanel 等
  - 配合 Chakra UI isLazy,切换 Tab 时才加载数据
- 首屏 API 请求从 9 个减少到 1 个

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 13:05:27 +08:00
zdl
38076534b1 perf: CompanyTabs 添加 isLazy 实现 Tab 懒加载
- 页面打开时只渲染第一个 Tab(CompanyOverview)
- 其他 Tab(深度分析、行情、财务、预测、动态跟踪)点击时才渲染和请求
- 减少首屏请求数量,提升加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:40:34 +08:00
zdl
a7ab87f7c4 feat: StockQuoteCard 顶部导航区视觉优化
- 股票名称字号放大至 26px,字重 800,突出显示
- 添加行业标签(金融 · 银行),Badge 边框样式
- 保留指数标签(沪深300、上证180)
- Mock 数据补充 industry、industry_l1、index_tags 字段
- 类型定义新增 industry、industryL1 可选字段

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:30:18 +08:00
zdl
9a77bb6f0b refactor: CompanyOverview 组件 TypeScript 拆分
- 新增 index.tsx: 主组件(组合层,50 行)
- 新增 CompanyHeaderCard.tsx: 头部卡片组件(168 行)
- 新增 hooks/useCompanyOverviewData.ts: 数据加载 Hook
- 删除 index.js: 原 330 行代码精简 85%
- 修复 Company/index.js: 恢复 CompanyTabs 渲染

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:21:02 +08:00
zdl
bf8847698b feat: CompanyOverview TypeScript 类型定义和工具函数
- types.ts: 添加公司基本信息、股东、管理层等接口定义
- utils.ts: 添加注册资本、日期格式化函数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:02:24 +08:00
zdl
7c83ffe008 perf: loadWatchlist 添加 localStorage 缓存(7天有效期)
- 添加 loadWatchlistFromCache/saveWatchlistToCache 缓存工具函数
- loadWatchlist 三级缓存策略:Redux → localStorage → API
- toggleWatchlist 成功后自动同步更新缓存
- 减少重复 API 请求,提升页面加载性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:01:33 +08:00
zdl
8786fa7b06 feat: StockQuoteCard 根据股票代码获取真实行情数据
- 新增 useStockQuote Hook 获取股票行情
- Company 页面使用 Hook 并传递数据给 StockQuoteCard
- StockQuoteCard 处理 null 数据显示骨架屏
- 股票代码变化时自动刷新行情数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:00:03 +08:00
zdl
0997cd9992 feat: 搜索栏交互优化 - 移除查询按钮,选择后直接跳转
- SearchBar: 移除"查询"按钮,简化交互
- SearchBar: 选择股票后直接触发搜索跳转
- useCompanyStock: handleSearch 支持直接传入股票代码参数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:52:04 +08:00
zdl
c8d704363d fix: 搜索框默认值改为空,避免下拉弹窗自动打开
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:35:01 +08:00
zdl
0de4a1f7af feat: SearchBar 模糊搜索功能
- SearchBar: 添加股票代码/名称模糊搜索下拉列表
- SearchBar: 使用 Redux allStocks 数据源进行过滤
- SearchBar: 点击外部自动关闭下拉,选择后自动搜索
- useCompanyStock: handleKeyPress 改为 handleKeyDown(兼容性优化)
- Company/index: 初始化时加载全部股票列表

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:25:31 +08:00
zdl
3382dd1036 feat: UI调整 2025-12-10 10:09:24 +08:00
zdl
9423094af2 pref: 移除 useColorModeValue
UI调整
2025-12-09 19:26:52 +08:00
zdl
4f38505a80 style: StockQuoteCard 黑金主题 UI 调整
颜色配置:
- 背景:纯黑 #000000
- 边框/标签:金色 #C9A961
- 主要文字:亮金 #F4D03F
- 涨:红色 #F44336(红涨绿跌)
- 跌:绿色 #4CAF50

字体大小:
- 股票价格:48px bold
- 股票名称/代码:24px bold
- 涨跌幅 Badge:20px bold
- 关键指标数值:16px bold
- 标签文字:14px

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:54:19 +08:00
zdl
4274341ed5 feat: 动态跟踪添加新闻动态二级 Tab
- 添加 Tabs 结构支持二级 Tab 扩展
- Tab1: 新闻动态(复用 NewsEventsTab 组件)
- 实现 loadNewsEvents 数据加载逻辑
- 支持搜索和分页功能
- 自动获取股票名称用于新闻搜索

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:37 +08:00
zdl
40f6eaced6 refactor: 移除暗色模式相关代码,使用固定浅色主题
- DeepAnalysisTab: 移除 useColorModeValue,使用固定颜色值
- NewsEventsTab: 移除 useColorModeValue,简化 hover 颜色
- FinancialPanorama: 移除 useColorMode/useColorModeValue
- MarketDataView: 移除 dark 主题配置,简化颜色逻辑
- StockQuoteCard: 移除 useColorModeValue,使用固定颜色

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 18:46:30 +08:00
zdl
2dd7dd755a refactor: Company 页面一级 Tab 重构为 6 个
- 新增深度分析 Tab(从 CompanyOverview 提取为独立组件)
- 新增动态跟踪 Tab(占位组件,后续添加内容)
- Tab 顺序:公司概览 | 深度分析 | 股票行情 | 财务全景 | 盈利预测 | 动态跟踪
- 简化 CompanyOverview:移除内部 Tabs,只保留头部卡片 + 基本信息
- DeepAnalysis 组件独立管理深度分析数据加载(3个接口)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:52:23 +08:00
zdl
04ce16df56 perf: CompanyOverview Tab 懒加载优化
- 拆分 loadData 为 loadBasicInfoData 和 loadDeepAnalysisData
- 首次加载仅请求 9 个基本信息接口(原 12 个)
- 深度分析 3 个接口切换 Tab 时按需加载
- 新闻动态 1 个接口切换 Tab 时按需加载
- 调整 Tab 顺序:基本信息 → 深度分析 → 新闻动态
- stockCode 变更时重置 Tab 状态和数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:37:11 +08:00
zdl
d7759b1da3 feat: 添加股票行情卡片 2025-12-09 17:26:58 +08:00
zdl
701f96855e feat: 添加mock数据 2025-12-09 17:24:54 +08:00
zdl
cd1a5b743f feat: 添加mock 2025-12-09 17:12:13 +08:00
zdl
18c83237e2 refactor: CompanyOverview 组件按 Tab 拆分为独立子组件
将 2682 行的大型组件拆分为 4 个模块化文件:
- index.js (~550行): 状态管理 + 数据加载 + Tab 容器
- DeepAnalysisTab.js (~1800行): 深度分析 Tab(核心定位、竞争力、产业链)
- BasicInfoTab.js (~940行): 基本信息 Tab(股权结构、管理团队、公告)
- NewsEventsTab.js (~540行): 新闻动态 Tab(事件列表 + 分页)

重构内容:
- 提取 8 个内部子组件到对应 Tab 文件
- 修复 useColorModeValue 在 map 回调中调用的 hooks 规则违规
- 清理未使用的 imports
- 完善公告详情模态框(补全 ModalFooter)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 17:11:42 +08:00
zdl
c1e10e6205 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_vf_h5_ui:
  feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
  feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新
  fix: 修复投资日历切换月份时自动打开事件弹窗的问题
  fix: 修复 CompanyOverview 中 Hooks 顺序错误
2025-12-09 16:36:46 +08:00
zdl
4954c58525 refactor: Company 目录结构重组 - Tab 内容组件文件夹化
- 将 4 个 Tab 内容组件移动到 components/ 目录下
  - CompanyOverview.js → components/CompanyOverview/index.js
  - MarketDataView.js → components/MarketDataView/index.js
  - FinancialPanorama.js → components/FinancialPanorama/index.js
  - ForecastReport.js → components/ForecastReport/index.js
- 更新 CompanyTabs/index.js 导入路径
- 更新 routes/lazy-components.js 路由路径
- 修复组件内相对路径导入,改用 @utils/@services 别名

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:31:58 +08:00
zdl
91bd581a5e feat: 添加 useCompanyStock 股票代码管理 2025-12-09 15:18:06 +08:00
zdl
258708fca0 fix: bug修复 2025-12-09 15:16:02 +08:00
zdl
90391729bb feat: 处理自选股乐观更新 2025-12-09 15:15:20 +08:00
zdl
2148d319ad feat: 添加mock 数据 2025-12-09 15:08:15 +08:00
zdl
c61d58b0e3 feat: 添加Company 页面 Tab 切换组件 2025-12-09 15:01:16 +08:00
zdl
ed1c7b9fa9 feat: 添加Company 页面头部组件 CompanyHeader
index.js            # 组合导出
SearchBar.js        # 股票搜索栏
WatchlistButton.js  # 自选股按钮
2025-12-09 14:59:24 +08:00
400 changed files with 47941 additions and 19862 deletions

1614
CLAUDE.md

File diff suppressed because it is too large Load Diff

1124
app.py

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

491
get_related_chg.py Normal file
View File

@@ -0,0 +1,491 @@
from clickhouse_driver import Client as Cclient
from sqlalchemy import create_engine, text
from datetime import datetime, time as dt_time, timedelta
import time
import pandas as pd
import os
# 读取交易日数据
script_dir = os.path.dirname(os.path.abspath(__file__))
TRADING_DAYS_FILE = os.path.join(script_dir, 'tdays.csv')
trading_days_df = pd.read_csv(TRADING_DAYS_FILE)
trading_days_df['DateTime'] = pd.to_datetime(trading_days_df['DateTime']).dt.date
TRADING_DAYS = sorted(trading_days_df['DateTime'].tolist()) # 排序后的交易日列表
def get_clickhouse_client():
return Cclient(
host='127.0.0.1',
port=9000,
user='default',
password='Zzl33818!',
database='stock'
)
def get_mysql_engine():
return create_engine(
"mysql+pymysql://root:Zzl33818!@127.0.0.1:3306/stock",
echo=False
)
def is_trading_time(check_datetime=None):
"""判断是否在交易时间内
Args:
check_datetime: 要检查的时间,默认为当前时间
Returns:
bool: True表示在交易时间内
"""
if check_datetime is None:
check_datetime = datetime.now()
# 检查是否是交易日
check_date = check_datetime.date()
if check_date not in TRADING_DAYS:
return False
# 检查是否在交易时段内
check_time = check_datetime.time()
# 上午时段: 9:30 - 11:30
morning_start = dt_time(9, 30)
morning_end = dt_time(11, 30)
# 下午时段: 13:00 - 15:00
afternoon_start = dt_time(13, 0)
afternoon_end = dt_time(15, 0)
is_morning = morning_start <= check_time <= morning_end
is_afternoon = afternoon_start <= check_time <= afternoon_end
return is_morning or is_afternoon
def get_next_trading_time():
"""获取下一个交易时段的开始时间"""
now = datetime.now()
current_date = now.date()
current_time = now.time()
# 如果今天是交易日
if current_date in TRADING_DAYS:
morning_start = dt_time(9, 30)
afternoon_start = dt_time(13, 0)
# 如果还没到上午开盘
if current_time < morning_start:
return datetime.combine(current_date, morning_start)
# 如果在上午休市后,下午还没开盘
elif dt_time(11, 30) < current_time < afternoon_start:
return datetime.combine(current_date, afternoon_start)
# 否则找下一个交易日的上午开盘时间
for td in TRADING_DAYS:
if td > current_date:
return datetime.combine(td, dt_time(9, 30))
# 如果没有找到未来交易日,返回明天上午9:30(可能需要更新交易日数据)
return datetime.combine(current_date + timedelta(days=1), dt_time(9, 30))
def get_next_trading_day(date):
"""获取下一个交易日"""
for td in TRADING_DAYS:
if td > date:
return td
return None
def get_nth_trading_day_after(start_date, n=7):
"""获取start_date之后的第n个交易日"""
try:
start_idx = TRADING_DAYS.index(start_date)
target_idx = start_idx + n
if target_idx < len(TRADING_DAYS):
return TRADING_DAYS[target_idx]
except (ValueError, IndexError):
pass
# 如果start_date不在交易日列表中找到它之后的交易日
future_days = [d for d in TRADING_DAYS if d > start_date]
if len(future_days) >= n:
return future_days[n - 1]
elif future_days:
return future_days[-1] # 返回最后一个可用的交易日
return None
def get_trading_day_info(event_datetime):
"""获取事件对应的交易日信息"""
event_date = event_datetime.date()
market_close = dt_time(15, 0)
# 如果是交易日且在收盘前,使用当天
if event_date in TRADING_DAYS and event_datetime.time() <= market_close:
return event_date
# 否则使用下一个交易日
return get_next_trading_day(event_date)
def calculate_stock_changes(stock_codes, event_datetime, ch_client, debug=False):
"""批量计算一个事件关联的所有股票涨跌幅"""
if not stock_codes:
return None, None, None
event_date = event_datetime.date()
event_time = event_datetime.time()
market_open = dt_time(9, 30)
market_close = dt_time(15, 0)
# 确定起始时间点(事件发生后的第一个有效价格点)
if event_date in TRADING_DAYS and market_open <= event_time <= market_close:
# 事件在交易时间内发生 → 用事件发生时的价格作为起点
start_datetime = event_datetime
trading_date = event_date
end_datetime = datetime.combine(trading_date, market_close)
if debug:
print(f" 事件在交易时间内: {event_datetime} -> 起点={start_datetime}")
else:
# 事件在交易时间外发生 → 用下一个交易日开盘价作为起点
trading_date = get_trading_day_info(event_datetime)
if not trading_date:
if debug:
print(f" 找不到交易日: {event_datetime}")
return None, None, None
start_datetime = datetime.combine(trading_date, market_open)
end_datetime = datetime.combine(trading_date, market_close)
if debug:
print(f" 事件在非交易时间: {event_datetime} -> 下一交易日={trading_date}, 起点={start_datetime}")
# 获取7个交易日后的日期
week_trading_date = get_nth_trading_day_after(trading_date, 7)
if not week_trading_date:
# 降级:如果没有足够的未来交易日,就用当前能找到的最远日期
week_trading_date = trading_date + timedelta(days=10)
week_end_datetime = datetime.combine(week_trading_date, market_close)
if debug:
print(f" 查询范围: {start_datetime} -> 当日={end_datetime}, 周末={week_end_datetime}")
print(f" 股票代码: {stock_codes}")
# 一次性查询所有股票的价格数据
results = ch_client.execute("""
SELECT code,
-- 起始价格:事件发生时或之后的第一个价格
argMin(close, timestamp) as start_price,
-- 当日收盘价:当日交易结束时的最后一个价格
argMax(
close, if(timestamp <= %(end)s, timestamp, toDateTime('1970-01-01'))
) as day_close_price,
-- 周后收盘价7个交易日后的收盘价
argMax(
close, if(timestamp <= %(week_end)s, timestamp, toDateTime('1970-01-01'))
) as week_close_price
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(week_end)s
GROUP BY code
HAVING start_price > 0
""", {
'codes': tuple(stock_codes),
'start': start_datetime,
'end': end_datetime,
'week_end': week_end_datetime
})
if debug:
print(f" 查询到 {len(results)} 只股票的数据")
if not results:
return None, None, None
# 计算涨跌幅
day_changes = []
week_changes = []
for code, start_price, day_close, week_close in results:
if start_price and start_price > 0:
# 当日涨跌幅(从事件发生到当日收盘)
if day_close and day_close > 0:
day_change = (day_close - start_price) / start_price * 100
day_changes.append(day_change)
# 周度涨跌幅从事件发生到第7个交易日收盘
if week_close and week_close > 0:
week_change = (week_close - start_price) / start_price * 100
week_changes.append(week_change)
# 计算统计值
avg_change = sum(day_changes) / len(day_changes) if day_changes else None
max_change = max(day_changes) if day_changes else None
avg_week_change = sum(week_changes) / len(week_changes) if week_changes else None
if debug:
print(
f" 结果: 日均={avg_change:.2f}% 日最大={max_change:.2f}% 周均={avg_week_change:.2f}%" if avg_change else " 结果: 无有效数据")
return avg_change, max_change, avg_week_change
def update_event_statistics(start_date=None, end_date=None, force_update=False, debug_mode=False):
"""更新事件统计数据
Args:
start_date: 开始日期
end_date: 结束日期
force_update: 是否强制更新(忽略已有数据)
debug_mode: 是否开启调试模式
"""
try:
print("[DEBUG] 开始 update_event_statistics")
print(f"[DEBUG] 参数: start_date={start_date}, end_date={end_date}, force_update={force_update}")
mysql_engine = get_mysql_engine()
print("[DEBUG] MySQL 引擎创建成功")
ch_client = get_clickhouse_client()
print("[DEBUG] ClickHouse 客户端创建成功")
with mysql_engine.connect() as mysql_conn:
print("[DEBUG] MySQL 连接已建立")
# 构建SQL查询
query = """
SELECT e.id, \
e.created_at, \
GROUP_CONCAT(rs.stock_code) as stock_codes,
e.related_avg_chg, \
e.related_max_chg, \
e.related_week_chg
FROM event e
JOIN related_stock rs ON e.id = rs.event_id \
"""
conditions = []
params = {}
if start_date:
conditions.append("e.created_at >= :start_date")
params["start_date"] = start_date
if end_date:
conditions.append("e.created_at <= :end_date")
params["end_date"] = end_date
if not force_update:
# 只更新没有数据的记录
conditions.append("(e.related_avg_chg IS NULL OR e.related_max_chg IS NULL)")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += """
GROUP BY e.id, e.created_at, e.related_avg_chg, e.related_max_chg, e.related_week_chg
ORDER BY e.created_at DESC
"""
print(f"[DEBUG] 执行查询SQL:\n{query}")
print(f"[DEBUG] 查询参数: {params}")
events = mysql_conn.execute(text(query), params).fetchall()
print(f"[DEBUG] 查询返回 {len(events)} 条事件记录")
print(f"Found {len(events)} events to update (force_update={force_update})")
if debug_mode and len(events) > 0:
print(f"Date range: {events[-1][1]} to {events[0][1]}")
# 准备批量更新数据
update_data = []
for idx, event in enumerate(events, 1):
try:
event_id = event[0]
created_at = event[1]
stock_codes = event[2].split(',') if event[2] else []
existing_avg = event[3]
existing_max = event[4]
existing_week = event[5]
if not stock_codes:
continue
if debug_mode and idx <= 3: # 只调试前3个事件
print(f"\n[Event {event_id}] created_at={created_at}")
if not force_update and existing_avg is not None:
print(
f" 已有数据: avg={existing_avg:.2f}% max={existing_max:.2f}% week={existing_week:.2f}%")
# 批量计算该事件所有股票的涨跌幅
avg_change, max_change, week_change = calculate_stock_changes(
stock_codes, created_at, ch_client, debug=(debug_mode and idx <= 3)
)
# 收集更新数据
if any(x is not None for x in (avg_change, max_change, week_change)):
update_data.append({
"avg_chg": avg_change,
"max_chg": max_change,
"week_chg": week_change,
"event_id": event_id
})
if idx <= 5: # 前5条显示详情
print(f"[DEBUG] 事件 {event_id}: avg={avg_change}, max={max_change}, week={week_change}")
else:
if idx <= 5:
print(f"[DEBUG] 事件 {event_id}: 计算结果全为None跳过")
# 每处理10个事件打印一次进度
if idx % 10 == 0:
print(f"Processed {idx}/{len(events)} events...")
except Exception as e:
print(f"Error processing event {event[0]}: {str(e)}")
if debug_mode:
import traceback
traceback.print_exc()
continue
# 批量更新MySQL
print(f"\n[DEBUG] ====== 准备写入数据库 ======")
print(f"[DEBUG] update_data 长度: {len(update_data)}")
if update_data:
print(f"[DEBUG] 前3条待更新数据: {update_data[:3]}")
print(f"[DEBUG] 执行 UPDATE 语句...")
result = mysql_conn.execute(text("""
UPDATE event
SET related_avg_chg = :avg_chg,
related_max_chg = :max_chg,
related_week_chg = :week_chg
WHERE id = :event_id
"""), update_data)
print(f"[DEBUG] UPDATE 执行完成, rowcount={result.rowcount}")
# 关键显式提交事务SQLAlchemy 2.0 需要手动 commit
print("[DEBUG] 准备提交事务 (commit)...")
mysql_conn.commit()
print("[DEBUG] 事务已提交!")
print(f"Successfully updated {len(update_data)} events")
else:
print("[DEBUG] update_data 为空,没有数据需要更新!")
except Exception as e:
print(f"Error in update_event_statistics: {str(e)}")
raise
def run_monitor():
"""运行监控循环 - 仅在交易时间段内每2分钟强制更新最近7天数据"""
print("=" * 60)
print("启动交易时段监控模式")
print("运行规则: 仅在交易日的9:30-11:30和13:00-15:00运行")
print("更新频率: 每2分钟一次")
print("更新模式: 强制更新(force_update=True)")
print("更新范围: 最近7天的事件数据")
print("=" * 60)
while True:
try:
now = datetime.now()
# 检查是否在交易时间内
if is_trading_time(now):
seven_days_ago = now - timedelta(days=7)
print(f"\n{'=' * 60}")
print(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] 交易时段 - 开始更新...")
print(f"{'=' * 60}")
update_event_statistics(
start_date=seven_days_ago,
force_update=True, # 强制更新所有数据
debug_mode=False
)
print(f"\n[{now.strftime('%Y-%m-%d %H:%M:%S')}] 更新完成")
print(f"等待2分钟后执行下次更新...\n")
time.sleep(120) # 2分钟
else:
# 不在交易时间,计算下次交易时间
next_trading_time = get_next_trading_time()
wait_seconds = (next_trading_time - now).total_seconds()
wait_minutes = int(wait_seconds / 60)
print(f"\n{'=' * 60}")
print(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] 非交易时段")
print(f"下次交易时间: {next_trading_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"等待时长: {wait_minutes} 分钟")
print(f"{'=' * 60}\n")
# 等待到下一个交易时段(每5分钟检查一次,避免程序僵死)
check_interval = 300 # 5分钟检查一次
while not is_trading_time():
time.sleep(min(check_interval, max(1, wait_seconds)))
wait_seconds = (get_next_trading_time() - datetime.now()).total_seconds()
if wait_seconds <= 0:
break
except KeyboardInterrupt:
print("\n程序被用户中断")
break
except Exception as e:
print(f"Error in monitor loop: {str(e)}")
import traceback
traceback.print_exc()
print("等待1分钟后重试...")
time.sleep(60) # 发生错误等待1分钟后重试
if __name__ == "__main__":
import sys
# 支持命令行参数
# python get_related_chg.py --test # 测试模式:只更新昨天和今天,开启调试
# python get_related_chg.py --once # 单次强制更新最近7天
# python get_related_chg.py # 正常运行交易时段每2分钟强制更新
if len(sys.argv) > 1:
if sys.argv[1] == '--test':
# 测试模式:更新昨天和今天的数据,开启调试
print("=" * 60)
print("测试模式:更新昨天和今天的数据")
print("=" * 60)
yesterday = (datetime.now() - timedelta(days=2)).replace(hour=15, minute=0, second=0)
tomorrow = datetime.now() + timedelta(days=1)
update_event_statistics(
start_date=yesterday,
end_date=tomorrow,
force_update=True,
debug_mode=True
)
print("\n测试完成!")
elif sys.argv[1] == '--once':
# 单次强制更新模式
print("=" * 60)
print("单次强制更新模式重新计算最近7天所有数据")
print("=" * 60)
seven_days_ago = datetime.now() - timedelta(days=7)
update_event_statistics(
start_date=seven_days_ago,
force_update=True,
debug_mode=False
)
print("\n强制更新完成!")
else:
print("未知参数。支持的参数:")
print(" --test : 测试模式(更新昨天和今天,开启调试)")
print(" --once : 单次强制更新最近7天")
print(" (无参数): 交易时段监控模式(每2分钟强制更新)")
else:
# 正常监控模式:仅在交易时间段运行
run_monitor()

View File

@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""
Gunicorn 配置文件 - Eventlet 极限高并发配置(110.42.32.207 专用)
Gunicorn 配置文件 - Eventlet 高并发配置(48核128GB 专用)
服务器配置: 48核心 128GB 内存
目标并发: 160,000+ 并发连接
目标并发: 5,000-10,000 实际并发(理论 320,000 连接
使用方式:
# 设置环境变量后启动
@@ -14,10 +14,12 @@ Gunicorn 配置文件 - Eventlet 极限高并发配置110.42.32.207 专用)
REDIS_HOST=127.0.0.1 gunicorn -c gunicorn_eventlet_config.py app:app
架构说明:
- 16 个 Eventlet Worker每个占用 1 核心,预留 32 核给系统/Redis/MySQL
- 32 个 Eventlet Worker每个占用 1 核心,预留 16 核给系统/Redis/MySQL
- 每个 Worker 处理 10000+ 并发连接(协程异步 I/O
- 数据库连接池: 32 workers × 150 = 4800 连接(实际瓶颈)
- Redis 消息队列同步跨 Worker 的 WebSocket 消息
- 并发能力: 16 × 10000 = 160,000+ 连接
- 理论并发能力: 32 × 10000 = 320,000 连接
- 实际并发能力: 5,000-10,000受数据库连接限制
"""
import os
@@ -32,9 +34,9 @@ os.environ.setdefault('REDIS_HOST', '127.0.0.1')
bind = '0.0.0.0:5001'
# Worker 进程数
# 48 核心机器: 16 Workers预留资源给 Redis/MySQL/系统
# 48 核心机器: 32 Workers目标 5000-10000 并发
# 每个 Eventlet Worker 是单线程但支持协程并发
workers = 16
workers = 32
# Worker 类型 - eventlet 异步模式
worker_class = 'eventlet'
@@ -97,14 +99,17 @@ def on_starting(server):
workers = server.app.cfg.workers
connections = server.app.cfg.worker_connections
total = workers * connections
db_pool = workers * 150 # pool_size=50 + max_overflow=100
print("=" * 70)
print("🚀 Gunicorn + Eventlet 极限高并发服务器正在启动...")
print("🚀 Gunicorn + Eventlet 高并发服务器正在启动...")
print("=" * 70)
print(f" 服务器配置: 48核心 128GB 内存")
print(f" Workers: {workers} 个 Eventlet 协程进程")
print(f" 每 Worker 连接数: {connections:,}")
print(f" 并发能力: {total:,} 连接")
print(f" 理论并发能力: {total:,} 连接")
print(f" 数据库连接池: {db_pool:,} 连接(实际瓶颈)")
print(f" 目标实际并发: 5,000-10,000")
print("-" * 70)
print(f" Bind: {server.app.cfg.bind}")
print(f" Max Requests: {server.app.cfg.max_requests:,}")
@@ -122,18 +127,21 @@ def when_ready(server):
workers = server.app.cfg.workers
connections = server.app.cfg.worker_connections
total = workers * connections
db_pool = workers * 150
print("=" * 70)
print(f"✅ Gunicorn + Eventlet 服务准备就绪!")
print(f" {workers} 个 Worker 已启动")
print(f" 并发能力: {total:,} 连接")
print(f" 理论并发能力: {total:,} 连接")
print(f" 数据库连接池: {db_pool:,} 连接")
print(f" 目标实际并发: 5,000-10,000")
print(f" WebSocket + HTTP API 混合高并发已启用")
print("=" * 70)
def post_worker_init(worker):
"""Worker 初始化完成后调用"""
print(f"✅ Eventlet Worker {worker.pid} 已初始化 (10,000 并发连接就绪)")
print(f"✅ Eventlet Worker {worker.pid} 已初始化 (10,000 并发连接 + 150 数据库连接就绪)")
# 触发事件轮询初始化(使用 Redis 锁确保只有一个 Worker 启动调度器)
try:

View File

@@ -14,10 +14,6 @@
"@fontsource/open-sans": "^4.5.0",
"@fontsource/raleway": "^4.5.0",
"@fontsource/roboto": "^4.5.0",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12",
"@tanstack/react-virtual": "^3.13.12",
@@ -28,29 +24,20 @@
"@visx/visx": "^3.12.0",
"@visx/wordcloud": "^3.12.0",
"antd": "^5.27.4",
"apexcharts": "^3.27.3",
"axios": "^1.10.0",
"classnames": "^2.5.1",
"craco-less": "^3.0.1",
"d3": "^7.9.0",
"date-fns": "^2.23.0",
"dayjs": "^1.11.19",
"draft-js": "^0.11.7",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"echarts-wordcloud": "^2.1.0",
"framer-motion": "^12.23.24",
"fullcalendar": "^5.9.0",
"globalize": "^1.7.0",
"history": "^5.3.0",
"klinecharts": "^10.0.0-beta1",
"lucide-react": "^0.540.0",
"match-sorter": "6.3.0",
"nouislider": "15.0.0",
"posthog-js": "^1.295.0",
"react": "^19.0.0",
"react-apexcharts": "^1.3.9",
"react-circular-slider-svg": "^0.1.5",
"react-custom-scrollbars-2": "^4.4.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.0",
@@ -62,16 +49,12 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.1",
"react-responsive-masonry": "^2.7.1",
"react-router-dom": "^6.30.1",
"react-scripts": "^5.0.1",
"react-scroll": "^1.8.4",
"react-scroll-into-view": "^2.1.3",
"react-table": "^7.7.0",
"react-tagsinput": "3.19.0",
"react-to-print": "^3.0.3",
"react-tsparticles": "^2.12.2",
"reagraph": "^4.27.0",
"recharts": "^3.1.2",
"remark-gfm": "^4.0.1",
"sass": "^1.49.9",
@@ -79,7 +62,6 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"three": "^0.181.2",
"typescript": "^5.9.3"
},
"resolutions": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -203,44 +203,46 @@ When it's NOT activated, the fc-button classes won't even be in the DOM.
}
.fc .fc-button-primary {
color: #fff;
color: var(--fc-button-text-color, #fff);
background-color: #805AD5;
background-color: var(--fc-button-bg-color, #805AD5);
border-color: #805AD5;
border-color: var(--fc-button-border-color, #805AD5);
color: #0A0A14;
color: var(--fc-button-text-color, #0A0A14);
background-color: #D4AF37;
background-color: var(--fc-button-bg-color, #D4AF37);
border-color: #D4AF37;
border-color: var(--fc-button-border-color, #D4AF37);
font-weight: 600;
}
.fc .fc-button-primary:hover {
color: #fff;
color: var(--fc-button-text-color, #fff);
background-color: #6B46C1;
background-color: var(--fc-button-hover-bg-color, #6B46C1);
border-color: #6B46C1;
border-color: var(--fc-button-hover-border-color, #6B46C1);
color: #0A0A14;
color: var(--fc-button-text-color, #0A0A14);
background-color: #B8960C;
background-color: var(--fc-button-hover-bg-color, #B8960C);
border-color: #B8960C;
border-color: var(--fc-button-hover-border-color, #B8960C);
}
.fc .fc-button-primary:disabled { /* not DRY */
color: #fff;
color: var(--fc-button-text-color, #fff);
background-color: #805AD5;
background-color: var(--fc-button-bg-color, #805AD5);
border-color: #805AD5;
border-color: var(--fc-button-border-color, #805AD5); /* overrides :hover */
color: #0A0A14;
color: var(--fc-button-text-color, #0A0A14);
background-color: #B8960C;
background-color: var(--fc-button-bg-color, #B8960C);
border-color: #B8960C;
border-color: var(--fc-button-border-color, #B8960C); /* overrides :hover */
opacity: 1;
}
.fc .fc-button-primary:focus {
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
}
.fc .fc-button-primary:not(:disabled):active,
.fc .fc-button-primary:not(:disabled).fc-button-active {
color: #fff;
color: var(--fc-button-text-color, #fff);
background-color: #6B46C1;
background-color: var(--fc-button-active-bg-color, #6B46C1);
border-color: #6B46C1;
border-color: var(--fc-button-active-border-color, #6B46C1);
color: #0A0A14;
color: var(--fc-button-text-color, #0A0A14);
background-color: #B8960C;
background-color: var(--fc-button-active-bg-color, #B8960C);
border-color: #B8960C;
border-color: var(--fc-button-active-border-color, #B8960C);
}
.fc .fc-button-primary:not(:disabled):active:focus,
.fc .fc-button-primary:not(:disabled).fc-button-active:focus {
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
}
.fc {

View File

@@ -0,0 +1,269 @@
/**
* BaseCalendar - 基础日历组件
* 封装 Ant Design Calendar提供统一的黑金主题和接口
*/
import React, { useCallback } from 'react';
import { Calendar, ConfigProvider, Button } from 'antd';
import type { CalendarProps } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import zhCN from 'antd/locale/zh_CN';
import { Box, HStack, Text } from '@chakra-ui/react';
import { CALENDAR_THEME, CALENDAR_COLORS, CALENDAR_STYLES } from './theme';
dayjs.locale('zh-cn');
/**
* 单元格渲染信息
*/
export interface CellRenderInfo {
type: 'date' | 'month';
isToday: boolean;
isCurrentMonth: boolean;
}
/**
* BaseCalendar Props
*/
export interface BaseCalendarProps {
/** 当前选中日期 */
value?: Dayjs;
/** 日期变化回调(月份切换等) */
onChange?: (date: Dayjs) => void;
/** 日期选择回调(点击日期) */
onSelect?: (date: Dayjs) => void;
/** 自定义单元格内容渲染 */
cellRender?: (date: Dayjs, info: CellRenderInfo) => React.ReactNode;
/** 日历高度 */
height?: string | number;
/** 是否显示工具栏 */
showToolbar?: boolean;
/** 工具栏标题格式 */
titleFormat?: string;
/** 额外的 className */
className?: string;
}
/**
* 默认工具栏组件
*/
const CalendarToolbar: React.FC<{
value: Dayjs;
onChange: (date: Dayjs) => void;
titleFormat?: string;
}> = ({ value, onChange, titleFormat = 'YYYY年M月' }) => {
const handlePrev = () => onChange(value.subtract(1, 'month'));
const handleNext = () => onChange(value.add(1, 'month'));
const handleToday = () => onChange(dayjs());
return (
<HStack justify="flex-start" mb={4} px={2} spacing={4}>
<HStack spacing={2}>
<Button
type="primary"
icon={<LeftOutlined />}
onClick={handlePrev}
style={{
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
color: CALENDAR_STYLES.toolbar.buttonColor,
}}
/>
<Button
type="primary"
icon={<RightOutlined />}
onClick={handleNext}
style={{
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
color: CALENDAR_STYLES.toolbar.buttonColor,
}}
/>
<Button
type="primary"
onClick={handleToday}
style={{
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
color: CALENDAR_STYLES.toolbar.buttonColor,
}}
>
</Button>
</HStack>
<Text
fontSize={{ base: 'lg', md: 'xl' }}
fontWeight="700"
bgGradient={`linear(135deg, ${CALENDAR_COLORS.gold.primary} 0%, #F5E6A3 100%)`}
bgClip="text"
>
{value.format(titleFormat)}
</Text>
</HStack>
);
};
/**
* BaseCalendar 组件
*/
export const BaseCalendar: React.FC<BaseCalendarProps> = ({
value,
onChange,
onSelect,
cellRender,
height = '100%',
showToolbar = true,
titleFormat = 'YYYY年M月',
className,
}) => {
const [currentValue, setCurrentValue] = React.useState<Dayjs>(value || dayjs());
// 同步外部 value
React.useEffect(() => {
if (value) {
setCurrentValue(value);
}
}, [value]);
// 处理日期变化
const handleChange = useCallback((date: Dayjs) => {
setCurrentValue(date);
onChange?.(date);
}, [onChange]);
// 处理日期选择(只在点击日期时触发,不在切换面板时触发)
const handleSelect: CalendarProps<Dayjs>['onSelect'] = useCallback((date: Dayjs, selectInfo) => {
// selectInfo.source: 'date' 表示点击日期,'month' 表示切换月份面板
// 只在点击日期时触发 onSelect
if (selectInfo.source === 'date') {
setCurrentValue(date);
onSelect?.(date);
}
}, [onSelect]);
// 自定义单元格渲染
const fullCellRender = useCallback((date: Dayjs) => {
const isToday = date.isSame(dayjs(), 'day');
const isCurrentMonth = date.isSame(currentValue, 'month');
const info: CellRenderInfo = {
type: 'date',
isToday,
isCurrentMonth,
};
// 基础日期单元格样式
const cellStyle: React.CSSProperties = {
minHeight: CALENDAR_STYLES.cell.minHeight,
padding: CALENDAR_STYLES.cell.padding,
borderRadius: '8px',
transition: 'all 0.2s ease',
cursor: 'pointer',
...(isToday ? {
backgroundColor: CALENDAR_STYLES.today.bg,
border: CALENDAR_STYLES.today.border,
} : {}),
};
return (
<div
style={cellStyle}
className="base-calendar-cell"
>
{/* 日期数字 */}
<div
style={{
textAlign: 'center',
fontSize: '14px',
fontWeight: isToday ? 700 : 600,
color: isToday
? CALENDAR_COLORS.gold.primary
: isCurrentMonth
? '#FFFFFF' // 纯白色,更亮
: 'rgba(255, 255, 255, 0.4)',
marginBottom: '4px',
}}
>
{date.date()}
</div>
{/* 自定义内容 */}
{cellRender?.(date, info)}
</div>
);
}, [currentValue, cellRender]);
// 隐藏默认 header
const headerRender = useCallback((): React.ReactNode => null, []);
return (
<Box height={height} className={className}>
<ConfigProvider theme={CALENDAR_THEME} locale={zhCN}>
{showToolbar && (
<CalendarToolbar
value={currentValue}
onChange={handleChange}
titleFormat={titleFormat}
/>
)}
<Box
height={showToolbar ? 'calc(100% - 60px)' : '100%'}
sx={{
// 日历整体样式
'.ant-picker-calendar': {
bg: 'transparent',
},
'.ant-picker-panel': {
bg: 'transparent',
border: 'none',
},
// 星期头 - 居中显示
'.ant-picker-content thead th': {
color: `${CALENDAR_COLORS.gold.primary} !important`,
fontWeight: '600 !important',
fontSize: '14px',
padding: '8px 0',
textAlign: 'center !important',
},
// 日期单元格
'.ant-picker-cell': {
padding: '2px',
},
'.ant-picker-cell-inner': {
width: '100%',
height: '100%',
},
// 非当前月份
'.ant-picker-cell-in-view': {
opacity: 1,
},
'.ant-picker-cell:not(.ant-picker-cell-in-view)': {
opacity: 0.5,
},
// hover 效果
'.base-calendar-cell:hover': {
bg: 'rgba(212, 175, 55, 0.1)',
},
// 选中状态
'.ant-picker-cell-selected .base-calendar-cell': {
bg: 'rgba(212, 175, 55, 0.15)',
},
}}
>
<Calendar
fullscreen={true}
value={currentValue}
onChange={handleChange}
onSelect={handleSelect}
fullCellRender={fullCellRender}
headerRender={headerRender}
/>
</Box>
</ConfigProvider>
</Box>
);
};
export default BaseCalendar;

View File

@@ -0,0 +1,142 @@
/**
* CalendarEventBlock - 日历事件块组件
* 用于在日历单元格中显示事件列表,支持多种事件类型和 "更多" 折叠
*/
import React, { useMemo } from 'react';
import { HStack, Text, Badge, VStack, Box } from '@chakra-ui/react';
import { CALENDAR_COLORS } from './theme';
/**
* 事件类型定义
*/
export type EventType = 'news' | 'report' | 'plan' | 'review' | 'system' | 'priceUp' | 'priceDown';
/**
* 日历事件接口
*/
export interface CalendarEvent {
id: string | number;
type: EventType;
title: string;
date: string;
count?: number;
data?: unknown;
}
/**
* 事件块 Props
*/
interface CalendarEventBlockProps {
events: CalendarEvent[];
maxDisplay?: number;
onEventClick?: (event: CalendarEvent) => void;
onMoreClick?: (events: CalendarEvent[]) => void;
compact?: boolean;
}
/**
* 事件类型配置
*/
const EVENT_CONFIG: Record<EventType, { label: string; color: string; emoji?: string }> = {
news: { label: '新闻', color: CALENDAR_COLORS.events.news, emoji: '📰' },
report: { label: '研报', color: CALENDAR_COLORS.events.report, emoji: '📊' },
plan: { label: '计划', color: CALENDAR_COLORS.events.plan },
review: { label: '复盘', color: CALENDAR_COLORS.events.review },
system: { label: '系统', color: CALENDAR_COLORS.events.system },
priceUp: { label: '涨', color: CALENDAR_COLORS.events.priceUp, emoji: '🔥' },
priceDown: { label: '跌', color: CALENDAR_COLORS.events.priceDown },
};
/**
* 单个事件行组件
*/
const EventLine: React.FC<{
event: CalendarEvent;
compact?: boolean;
onClick?: () => void;
}> = ({ event, compact, onClick }) => {
const config = EVENT_CONFIG[event.type] || { label: event.type, color: '#888' };
return (
<Box
fontSize={compact ? '9px' : '10px'}
color={config.color}
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
w="100%"
overflow="hidden"
textAlign="left"
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
>
{/* 格式计划年末布局XX+1 */}
<Text fontWeight="600" fontSize={compact ? '9px' : '10px'} isTruncated>
{config.emoji ? `${config.emoji} ` : ''}{config.label}{event.title || ''}
{(event.count ?? 0) > 1 && <Text as="span" color={config.color}>+{(event.count ?? 1) - 1}</Text>}
</Text>
</Box>
);
};
/**
* 日历事件块组件
*/
export const CalendarEventBlock: React.FC<CalendarEventBlockProps> = ({
events,
maxDisplay = 3,
onEventClick,
onMoreClick,
compact = false,
}) => {
// 计算显示的事件和剩余事件
const { displayEvents, remainingCount, remainingEvents } = useMemo(() => {
if (events.length <= maxDisplay) {
return { displayEvents: events, remainingCount: 0, remainingEvents: [] };
}
return {
displayEvents: events.slice(0, maxDisplay),
remainingCount: events.length - maxDisplay,
remainingEvents: events.slice(maxDisplay),
};
}, [events, maxDisplay]);
if (events.length === 0) return null;
return (
<VStack spacing={0} align="stretch" w="100%">
{displayEvents.map((event) => (
<EventLine
key={event.id}
event={event}
compact={compact}
onClick={() => onEventClick?.(event)}
/>
))}
{remainingCount > 0 && (
<Box
fontSize="9px"
color={CALENDAR_COLORS.text.secondary}
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
onClick={(e) => {
e.stopPropagation();
onMoreClick?.(remainingEvents);
}}
>
<Text>+{remainingCount} </Text>
</Box>
)}
</VStack>
);
};
export default CalendarEventBlock;

View File

@@ -0,0 +1,19 @@
/**
* Calendar 公共组件库
* 统一的日历组件,基于 Ant Design Calendar + 黑金主题
*/
// 基础日历组件
export { BaseCalendar } from './BaseCalendar';
export type { BaseCalendarProps, CellRenderInfo } from './BaseCalendar';
// 事件块组件
export { CalendarEventBlock } from './CalendarEventBlock';
export type { CalendarEvent, EventType } from './CalendarEventBlock';
// 主题配置
export {
CALENDAR_THEME,
CALENDAR_COLORS,
CALENDAR_STYLES,
} from './theme';

View File

@@ -0,0 +1,111 @@
/**
* Calendar 黑金主题配置
* 统一的 Ant Design Calendar 主题,用于所有日历组件
*/
import type { ThemeConfig } from 'antd';
// 黑金主题色值
export const CALENDAR_COLORS = {
// 主色
gold: {
primary: '#D4AF37',
secondary: '#B8960C',
gradient: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
},
// 背景色
bg: {
deep: '#0A0A14',
primary: '#0F0F1A',
elevated: '#1A1A2E',
surface: '#252540',
},
// 边框色
border: {
subtle: 'rgba(212, 175, 55, 0.1)',
default: 'rgba(212, 175, 55, 0.2)',
emphasis: 'rgba(212, 175, 55, 0.4)',
},
// 文字色
text: {
primary: 'rgba(255, 255, 255, 0.95)',
secondary: 'rgba(255, 255, 255, 0.6)',
muted: 'rgba(255, 255, 255, 0.4)',
},
// 事件类型颜色
events: {
news: '#9F7AEA', // 紫色 - 新闻
report: '#805AD5', // 深紫 - 研报
plan: '#D4AF37', // 金色 - 计划
review: '#10B981', // 绿色 - 复盘
system: '#3B82F6', // 蓝色 - 系统事件
priceUp: '#FC8181', // 红色 - 上涨
priceDown: '#68D391', // 绿色 - 下跌
},
} as const;
/**
* Ant Design Calendar 黑金主题配置
*/
export const CALENDAR_THEME: ThemeConfig = {
token: {
// 基础色
colorBgContainer: 'transparent',
colorBgElevated: CALENDAR_COLORS.bg.elevated,
colorText: CALENDAR_COLORS.text.primary,
colorTextSecondary: CALENDAR_COLORS.text.secondary,
colorTextTertiary: CALENDAR_COLORS.text.muted,
colorTextHeading: CALENDAR_COLORS.gold.primary,
// 边框
colorBorder: CALENDAR_COLORS.border.default,
colorBorderSecondary: CALENDAR_COLORS.border.subtle,
// 主色
colorPrimary: CALENDAR_COLORS.gold.primary,
colorPrimaryHover: CALENDAR_COLORS.gold.secondary,
colorPrimaryActive: CALENDAR_COLORS.gold.secondary,
// 链接色
colorLink: CALENDAR_COLORS.gold.primary,
colorLinkHover: CALENDAR_COLORS.gold.secondary,
// 圆角
borderRadius: 8,
borderRadiusLG: 12,
},
components: {
Calendar: {
// 日历整体背景
fullBg: 'transparent',
fullPanelBg: 'transparent',
// 选中项背景
itemActiveBg: 'rgba(212, 175, 55, 0.15)',
},
},
};
/**
* 日历样式常量(用于内联样式或 CSS-in-JS
*/
export const CALENDAR_STYLES = {
// 今天高亮
today: {
bg: 'rgba(212, 175, 55, 0.1)',
border: `2px solid ${CALENDAR_COLORS.gold.primary}`,
},
// 日期单元格
cell: {
minHeight: '85px',
padding: '4px',
},
// 工具栏
toolbar: {
buttonBg: CALENDAR_COLORS.gold.primary,
buttonColor: CALENDAR_COLORS.bg.deep,
buttonHoverBg: CALENDAR_COLORS.gold.secondary,
},
};
export default CALENDAR_THEME;

View File

@@ -1,40 +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.
*/
import React from 'react';
import FullCalendar from '@fullcalendar/react'; // must go before plugins
import dayGridPlugin from '@fullcalendar/daygrid'; // a plugin!
import interactionPlugin from '@fullcalendar/interaction'; // needed for dayClick
function EventCalendar(props) {
const { calendarData, initialDate } = props;
return (
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
headerToolbar={false}
initialView='dayGridMonth'
initialDate={initialDate}
contentHeight='600'
events={calendarData}
editable={true}
height='100%'
/>
);
}
export default EventCalendar;

View File

@@ -1,50 +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.
*/
import React, { Component } from "react";
import Chart from "react-apexcharts";
class BarChart extends Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<Chart
options={this.state.chartOptions}
series={this.state.chartData}
type="bar"
width="100%"
height="100%"
/>
);
}
}
export default BarChart;

View File

@@ -1,50 +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.
*/
import React, { Component } from "react";
import Chart from "react-apexcharts";
class BubbleChart extends Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<Chart
options={this.state.chartOptions}
series={this.state.chartData}
type="bubble"
width="100%"
height="100%"
/>
);
}
}
export default BubbleChart;

View File

@@ -1,51 +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.
*/
import React from "react";
import ReactApexChart from "react-apexcharts";
class DonutChart extends React.Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<ReactApexChart
options={this.state.chartOptions}
series={this.state.chartData}
type="donut"
width="100%"
height="100%"
/>
);
}
}
export default DonutChart;

View File

@@ -0,0 +1,40 @@
/**
* ECharts 包装组件
*
* 基于 echarts-for-react使用按需引入的 echarts 实例
* 减少打包体积约 500KB
*
* @example
* ```tsx
* import ECharts from '@components/Charts/ECharts';
*
* <ECharts option={chartOption} style={{ height: 300 }} />
* ```
*/
import React, { forwardRef } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import { echarts } from '@lib/echarts';
// Re-export ReactEChartsCore props type
import type { EChartsReactProps } from 'echarts-for-react';
export type EChartsProps = Omit<EChartsReactProps, 'echarts'>;
/**
* ECharts 图表组件
* 自动使用按需引入的 echarts 实例
*/
const ECharts = forwardRef<ReactEChartsCore, EChartsProps>((props, ref) => {
return (
<ReactEChartsCore
ref={ref}
echarts={echarts}
{...props}
/>
);
});
ECharts.displayName = 'ECharts';
export default ECharts;

View File

@@ -1,50 +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.
*/
import React, { Component } from "react";
import Chart from "react-apexcharts";
class LineBarChart extends Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<Chart
options={this.state.chartOptions}
series={this.state.chartData}
type="line"
width="100%"
height="100%"
/>
);
}
}
export default LineBarChart;

View File

@@ -1,51 +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.
*/
import React from "react";
import ReactApexChart from "react-apexcharts";
class LineChart extends React.Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<ReactApexChart
options={this.state.chartOptions}
series={this.state.chartData}
type="area"
width="100%"
height="100%"
/>
);
}
}
export default LineChart;

View File

@@ -1,51 +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.
*/
import React from "react";
import ReactApexChart from "react-apexcharts";
class PieChart extends React.Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<ReactApexChart
options={this.state.chartOptions}
series={this.state.chartData}
type="pie"
width="100%"
height="100%"
/>
);
}
}
export default PieChart;

View File

@@ -1,51 +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.
*/
import React from "react";
import ReactApexChart from "react-apexcharts";
class PolarChart extends React.Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<ReactApexChart
options={this.state.chartOptions}
series={this.state.chartData}
type="polarArea"
width="100%"
height="100%"
/>
);
}
}
export default PolarChart;

View File

@@ -1,51 +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.
*/
import React from "react";
import ReactApexChart from "react-apexcharts";
class RadarChart extends React.Component {
constructor(props) {
super(props);
this.state = {
chartData: [],
chartOptions: {},
};
}
componentDidMount() {
this.setState({
chartData: this.props.chartData,
chartOptions: this.props.chartOptions,
});
}
render() {
return (
<ReactApexChart
options={this.state.chartOptions}
series={this.state.chartData}
type="radar"
width="100%"
height="100%"
/>
);
}
}
export default RadarChart;

View File

@@ -1,7 +1,7 @@
// src/components/Charts/Stock/MiniTimelineChart.js
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import { echarts } from '@lib/echarts';
import dayjs from 'dayjs';
import {
fetchKlineData,

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useRef } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts';
import { echarts } from '@lib/echarts';
/**
* ECharts 图表渲染组件

View File

@@ -10,9 +10,9 @@ import {
Badge,
IconButton,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 可折叠模块标题组件
@@ -38,9 +38,10 @@ const CollapsibleHeader = ({
onModeToggle = null,
isLocked = false
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 深色主题 - 标题区块背景稍亮
const sectionBg = '#3D4A5C';
const hoverBg = '#4A5568';
const headingColor = '#F7FAFC';
// 获取按钮文案
const getButtonText = () => {

View File

@@ -5,10 +5,8 @@ import React, { useState } from 'react';
import {
Box,
Collapse,
useColorModeValue,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 通用可折叠区块组件
@@ -38,7 +36,8 @@ const CollapsibleSection = ({
showModeToggle = false,
defaultMode = 'detailed'
}) => {
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
// 深色主题 - 折叠区块背景稍亮
const sectionBg = '#354259';
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);

View File

@@ -86,9 +86,10 @@ const sectionReducer = (state, action) => {
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch();
const { user } = useAuth();
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
// 深色主题 - 与弹窗背景一致
const cardBg = '#2D3748';
const borderColor = 'rgba(255, 255, 255, 0.08)';
const textColor = '#CBD5E0';
// 使用 useWatchlist Hook 管理自选股
const {
@@ -198,10 +199,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
}
}, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => {
@@ -350,13 +347,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
)}
</CollapsibleSection>
{/* 相关概念(可折叠 - 需要 PRO 权限 */}
{/* 相关概念(手风琴样式 - 需要 PRO 权限 */}
<RelatedConceptsSection
eventId={event.id}
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
onLockedClick={() => handleLockedClick('相关概念', 'pro')}

View File

@@ -19,8 +19,9 @@ import ConceptStockItem from './ConceptStockItem';
/**
* 详细概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API 和 related_concepts 表数据
* - concept: 概念名称
* - reason: 关联原因(来自 related_concepts 表)
* - stock_count: 相关股票数量
* - score: 相关度0-1
* - price_info.avg_change_pct: 平均涨跌幅
@@ -34,6 +35,8 @@ const DetailedConceptCard = ({ concept, onClick }) => {
const borderColor = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
const reasonBg = useColorModeValue('blue.50', 'blue.900');
const reasonColor = useColorModeValue('gray.700', 'gray.200');
// 计算相关度百分比
const relevanceScore = Math.round((concept.score || 0) * 100);
@@ -43,6 +46,9 @@ const DetailedConceptCard = ({ concept, onClick }) => {
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
const changeSymbol = changePct > 0 ? '+' : '';
// 判断是否来自数据库(有 reason 字段)
const isFromDatabase = !!concept.reason;
return (
<Card
bg={cardBg}
@@ -67,17 +73,27 @@ const DetailedConceptCard = ({ concept, onClick }) => {
{concept.concept}
</Text>
<HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs">
相关度: {relevanceScore}%
</Badge>
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
{/* 数据库数据显示"AI分析"标签,搜索数据显示相关度 */}
{isFromDatabase ? (
<Badge colorScheme="green" fontSize="xs">
AI 分析
</Badge>
) : (
<Badge colorScheme="purple" fontSize="xs">
相关度: {relevanceScore}%
</Badge>
)}
{/* 只有搜索数据才显示股票数量 */}
{!isFromDatabase && concept.stock_count > 0 && (
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
)}
</HStack>
</VStack>
{/* 右侧:涨跌幅 */}
{concept.price_info?.avg_change_pct && (
{/* 右侧:涨跌幅(仅搜索数据有) */}
{!isFromDatabase && concept.price_info?.avg_change_pct && (
<Box textAlign="right">
<Text fontSize="xs" color={stockCountColor} mb={1}>
平均涨跌幅
@@ -97,8 +113,30 @@ const DetailedConceptCard = ({ concept, onClick }) => {
<Divider />
{/* 概念描述 */}
{concept.description && (
{/* 关联原因(来自数据库,突出显示) */}
{concept.reason && (
<Box
bg={reasonBg}
p={3}
borderRadius="md"
borderLeft="4px solid"
borderLeftColor="blue.400"
>
<Text fontSize="xs" fontWeight="bold" color="blue.500" mb={1}>
关联原因
</Text>
<Text
fontSize="sm"
color={reasonColor}
lineHeight="1.8"
>
{concept.reason}
</Text>
</Box>
)}
{/* 概念描述(仅搜索数据有,且没有 reason 时显示) */}
{!concept.reason && concept.description && (
<Text
fontSize="sm"
color={stockCountColor}

View File

@@ -14,10 +14,11 @@ import {
/**
* 简单概念卡片组件
* @param {Object} props
* @param {Object} props.concept - 概念对象
* - name: 概念名称
* @param {Object} props.concept - 概念对象(兼容搜索数据和数据库数据)
* - concept: 概念名称
* - reason: 关联原因(来自数据库)
* - stock_count: 相关股票数量
* - relevance: 相关度0-100
* - score: 相关度0-1
* @param {Function} props.onClick - 点击回调
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
*/
@@ -34,13 +35,16 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
// 判断是否来自数据库(有 reason 字段)
const isFromDatabase = !!concept.reason;
return (
<VStack
align="stretch"
spacing={1}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderColor={isFromDatabase ? 'green.300' : borderColor}
borderRadius="md"
px={2}
py={1}
@@ -61,30 +65,39 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
wordBreak="break-word"
lineHeight="1.4"
>
{concept.concept}{' '}
<Text as="span" color="gray.500">
({concept.stock_count})
</Text>
{concept.concept}
{/* 只有搜索数据才显示股票数量 */}
{!isFromDatabase && concept.stock_count > 0 && (
<Text as="span" color="gray.500">
{' '}({concept.stock_count})
</Text>
)}
</Text>
{/* 第二行:相关度 + 涨跌幅 */}
{/* 第二行:标签 */}
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
{/* 相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={1.5}
py={0.5}
borderRadius="sm"
flexShrink={0}
>
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
{/* 数据库数据显示"AI分析",搜索数据显示相关度 */}
{isFromDatabase ? (
<Badge colorScheme="green" fontSize="10px" px={1.5} py={0.5}>
AI 分析
</Badge>
) : (
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={1.5}
py={0.5}
borderRadius="sm"
flexShrink={0}
>
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
)}
{/* 涨跌幅数据 */}
{changePct !== null && (
{/* 涨跌幅数据(仅搜索数据有) */}
{!isFromDatabase && changePct !== null && (
<Badge
colorScheme={changeColor}
fontSize="10px"

View File

@@ -1,83 +1,116 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件(主组件)
// 相关概念区组件 - 便当盒网格布局
import React, { useState, useEffect } from 'react';
import {
Box,
SimpleGrid,
Flex,
Button,
Collapse,
Heading,
Center,
Spinner,
Text,
Badge,
SimpleGrid,
HStack,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
/**
* 单个概念卡片组件(便当盒样式)
*/
const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
// 深色主题固定颜色
const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
const borderColor = 'rgba(252, 129, 129, 0.3)';
const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
const handleClick = () => {
if (isLocked && onLockedClick) {
onLockedClick();
return;
}
onNavigate(concept);
};
return (
<Tooltip
label={concept.reason || concept.concept}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={2}
borderRadius="md"
maxW="300px"
fontSize="xs"
>
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
px={3}
py={2}
cursor="pointer"
onClick={handleClick}
_hover={{
bg: cardHoverBg,
transform: 'translateY(-1px)',
boxShadow: 'sm',
}}
transition="all 0.15s ease"
textAlign="center"
>
<Text
fontSize="sm"
fontWeight="semibold"
color={conceptColor}
noOfLines={1}
>
{concept.concept}
</Text>
</Box>
</Tooltip>
);
};
/**
* 相关概念区组件
* @param {Object} props
* @param {string} props.eventTitle - 事件标题(用于搜索概念
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期
* @param {string|Object} props.eventTime - 事件发生时间
* @param {number} props.eventId - 事件ID(用于获取 related_concepts 表数据
* @param {string} props.eventTitle - 事件标题(备用
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
* @param {boolean} props.isLocked - 是否锁定(需要付费)
* @param {Function} props.onLockedClick - 锁定时的点击回调
*/
const RelatedConceptsSection = ({
eventId,
eventTitle,
effectiveTradingDate,
eventTime,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
onToggle = undefined // 新增:受控模式(外部控制展开回调)
}) => {
// 使用外部 isOpen如果没有则使用内部 useState
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
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');
// 颜色配置 - 使用深色主题固定颜色
const sectionBg = 'transparent';
const headingColor = '#e2e8f0';
const textColor = '#a0aec0';
const countBadgeBg = '#3182ce';
const countBadgeColor = '#ffffff';
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
});
const fetchConcepts = async () => {
if (!eventId) {
setLoading(false);
return;
}
@@ -86,178 +119,87 @@ const RelatedConceptsSection = ({
setLoading(true);
setError(null);
// 格式化交易日期 - 统一使用 moment 处理
let formattedTradeDate;
try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
// 验证日期是否有效
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = dayjs().format('YYYY-MM-DD');
}
} catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = dayjs().format('YYYY-MM-DD');
}
const requestBody = {
query: eventTitle,
size: 5,
page: 1,
sort_by: "_score",
trade_date: formattedTradeDate
};
const apiUrl = `${getApiBase()}/concept-api/search`;
console.log('[RelatedConceptsSection] 发送请求', {
url: apiUrl,
requestBody
});
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
console.log('[RelatedConceptsSection] 响应状态', {
ok: response.ok,
status: response.status,
statusText: response.statusText
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (!response.ok) {
if (response.status === 403) {
setConcepts([]);
setLoading(false);
return;
}
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);
if (data.success && Array.isArray(data.data)) {
setConcepts(data.data);
} else {
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
setConcepts([]);
}
} catch (err) {
console.error('[RelatedConceptsSection] 搜索概念失败', err);
logger.error('RelatedConceptsSection', 'searchConcepts', err);
console.error('[RelatedConceptsSection] 获取概念失败', err);
logger.error('RelatedConceptsSection', 'fetchConcepts', err);
setError('加载概念数据失败');
setConcepts([]);
} finally {
console.log('[RelatedConceptsSection] 加载完成');
setLoading(false);
}
};
searchConcepts();
}, [eventTitle, effectiveTradingDate]);
fetchConcepts();
}, [eventId]);
// 跳转到概念中心
const handleNavigate = (concept) => {
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
};
// 加载中状态
if (loading) {
return (
<Box bg={sectionBg} p={3} borderRadius="md">
<Center py={4}>
<Spinner size="md" color="blue.500" mr={2} />
<Spinner size="sm" color="blue.500" mr={2} />
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
</Center>
</Box>
);
}
// 判断是否有数据
const hasNoConcepts = !concepts || concepts.length === 0;
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
* @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">
{/* 标题栏 - 两行布局 */}
<Box mb={3}>
{/* 第一行:标题 + Badge + 按钮 */}
<Flex justify="space-between" align="center" mb={2}>
<Flex align="center" gap={2}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
{/* 订阅徽章 */}
{subscriptionBadge}
</Flex>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={() => {
// 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) {
onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else {
// 非受控模式:使用内部状态
setInternalExpanded(!internalExpanded);
}
}}
>
{isExpanded ? '收起' : '查看详细'}
</Button>
</Flex>
{/* 第二行:交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
</Box>
{/* 标题栏 */}
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={2}>
<Heading size="sm" color={headingColor}>
相关概念
</Heading>
{!hasNoConcepts && (
<Badge
bg={countBadgeBg}
color={countBadgeColor}
fontSize="xs"
px={2}
py={0.5}
borderRadius="full"
>
{concepts.length}
</Badge>
)}
{subscriptionBadge}
</HStack>
</Flex>
{/* 简单模式:横向卡片列表(总是显示) */}
{/* 概念列表 - 便当盒网格布局 */}
{hasNoConcepts ? (
<Box mb={isExpanded ? 3 : 0}>
<Box py={2}>
{error ? (
<Text color="red.500" fontSize="sm">{error}</Text>
) : (
@@ -265,41 +207,18 @@ const RelatedConceptsSection = ({
)}
</Box>
) : (
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
{concepts.map((concept, index) => (
<SimpleConceptCard
key={index}
<ConceptCard
key={concept.id || index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
onNavigate={handleNavigate}
isLocked={isLocked}
onLockedClick={onLockedClick}
/>
))}
</Flex>
</SimpleGrid>
)}
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{hasNoConcepts ? (
<Box py={4}>
{error ? (
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
) : (
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
)}
</Box>
) : (
/* 详细概念卡片网格 */
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
)}
</Collapse>
</Box>
);
};

View File

@@ -0,0 +1,81 @@
/**
* 环境光效果组件
* James Turrell 风格的背景光晕效果
*/
import React, { memo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
export interface AmbientGlowProps extends Omit<BoxProps, 'bg'> {
/** 预设主题 */
variant?: 'default' | 'gold' | 'blue' | 'purple' | 'warm';
/** 自定义渐变(覆盖 variant */
customGradient?: string;
}
// 预设光效配置
const GLOW_VARIANTS = {
default: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.08), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(100, 200, 255, 0.04), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 200, 100, 0.04), transparent 40%)
`,
gold: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.12), transparent 50%),
radial-gradient(ellipse 80% 60% at 20% 80%, rgba(212, 175, 55, 0.06), transparent 40%),
radial-gradient(ellipse 80% 60% at 80% 80%, rgba(255, 200, 100, 0.05), transparent 40%)
`,
blue: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(100, 200, 255, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(60, 160, 255, 0.06), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(140, 220, 255, 0.05), transparent 40%)
`,
purple: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(160, 100, 255, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(200, 150, 255, 0.05), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(120, 80, 255, 0.05), transparent 40%)
`,
warm: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(255, 150, 100, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(255, 200, 150, 0.05), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 180, 120, 0.05), transparent 40%)
`,
};
/**
* 环境光效果组件
* 创建 James Turrell 风格的微妙背景光晕
*
* @example
* ```tsx
* <Box position="relative">
* <AmbientGlow variant="gold" />
* {children}
* </Box>
* ```
*/
const AmbientGlow = memo<AmbientGlowProps>(({
variant = 'default',
customGradient,
...boxProps
}) => {
const gradient = customGradient || GLOW_VARIANTS[variant];
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
pointerEvents="none"
zIndex={0}
bg={gradient}
{...boxProps}
/>
);
});
AmbientGlow.displayName = 'AmbientGlow';
export default AmbientGlow;

View File

@@ -0,0 +1,140 @@
/**
* CardGlow - 卡片级装饰光效组件
*
* 为卡片提供 FUI 风格的装饰元素:
* - 顶部光条Ash Thorp 风格)
* - 角落发光效果James Turrell 风格)
* - 可选背景网格
*
* 与 AmbientGlow 的区别:
* - AmbientGlow: 页面级环境光position: fixed
* - CardGlow: 卡片级装饰光,相对于父容器定位
*
* @example
* ```tsx
* <Box position="relative" overflow="hidden">
* <CardGlow variant="gold" />
* {children}
* </Box>
* ```
*/
import React, { memo } from 'react';
import { Box } from '@chakra-ui/react';
export interface CardGlowProps {
/** 预设主题 */
variant?: 'gold' | 'cyan' | 'purple' | 'default';
/** 是否显示背景网格 */
showGrid?: boolean;
/** 自定义主色(覆盖 variant */
primaryColor?: string;
/** 自定义次色(覆盖 variant */
secondaryColor?: string;
}
// 预设颜色配置
const COLOR_PRESETS = {
gold: {
primary: 'rgba(212, 175, 55, 1)',
secondary: 'rgba(0, 212, 255, 0.1)',
grid: 'rgba(212, 175, 55, 0.03)',
},
cyan: {
primary: 'rgba(0, 212, 255, 1)',
secondary: 'rgba(212, 175, 55, 0.1)',
grid: 'rgba(0, 212, 255, 0.03)',
},
purple: {
primary: 'rgba(168, 85, 247, 1)',
secondary: 'rgba(0, 212, 255, 0.1)',
grid: 'rgba(168, 85, 247, 0.03)',
},
default: {
primary: 'rgba(255, 255, 255, 0.6)',
secondary: 'rgba(255, 255, 255, 0.1)',
grid: 'rgba(255, 255, 255, 0.02)',
},
};
/**
* 卡片装饰光效组件
*
* 纯展示组件,需要父容器设置 position: relative 和 overflow: hidden
*/
const CardGlow = memo<CardGlowProps>(({
variant = 'gold',
showGrid = true,
primaryColor,
secondaryColor,
}) => {
const preset = COLOR_PRESETS[variant];
const primary = primaryColor || preset.primary;
const secondary = secondaryColor || preset.secondary;
const gridColor = preset.grid;
return (
<>
{/* 顶部光条 - Ash Thorp 风格数据终端效果 */}
<Box
position="absolute"
top={0}
left="50%"
transform="translateX(-50%)"
width="60%"
height="1px"
background={`linear-gradient(90deg, transparent, ${primary}, transparent)`}
opacity={0.6}
pointerEvents="none"
/>
{/* 左上角光晕 - James Turrell 风格光影效果 */}
<Box
position="absolute"
top="-40px"
left="-40px"
width="80px"
height="80px"
borderRadius="50%"
background={`radial-gradient(circle, ${primary.replace('1)', '0.15)')} 0%, transparent 70%)`}
filter="blur(20px)"
pointerEvents="none"
/>
{/* 右下角光晕 - 补充色,增加层次感 */}
<Box
position="absolute"
bottom="-40px"
right="-40px"
width="80px"
height="80px"
borderRadius="50%"
background={`radial-gradient(circle, ${secondary} 0%, transparent 70%)`}
filter="blur(20px)"
pointerEvents="none"
/>
{/* 背景网格 - 微妙的科技感纹理 */}
{showGrid && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
backgroundImage={`
linear-gradient(${gridColor} 1px, transparent 1px),
linear-gradient(90deg, ${gridColor} 1px, transparent 1px)
`}
backgroundSize="40px 40px"
pointerEvents="none"
opacity={0.5}
/>
)}
</>
);
});
CardGlow.displayName = 'CardGlow';
export default CardGlow;

View File

@@ -0,0 +1,93 @@
/**
* FUI 毛玻璃容器组件
* 科幻风格的 Glassmorphism 容器,带角落装饰
*/
import React, { memo, ReactNode } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import FuiCorners, { FuiCornersProps } from './FuiCorners';
export interface FuiContainerProps extends Omit<BoxProps, 'children'> {
children: ReactNode;
/** 是否显示角落装饰 */
showCorners?: boolean;
/** 角落装饰配置 */
cornersProps?: FuiCornersProps;
/** 预设主题 */
variant?: 'default' | 'gold' | 'blue' | 'dark';
}
// 预设主题配置
const VARIANTS = {
default: {
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
borderColor: 'rgba(212, 175, 55, 0.15)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
cornerColor: 'rgba(212, 175, 55, 0.4)',
},
gold: {
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
borderColor: 'rgba(212, 175, 55, 0.2)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(212, 175, 55, 0.1)',
cornerColor: 'rgba(212, 175, 55, 0.5)',
},
blue: {
bg: 'linear-gradient(145deg, rgba(20, 30, 48, 0.95) 0%, rgba(10, 15, 26, 0.98) 100%)',
borderColor: 'rgba(100, 200, 255, 0.15)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(100, 200, 255, 0.05)',
cornerColor: 'rgba(100, 200, 255, 0.4)',
},
dark: {
bg: 'linear-gradient(145deg, rgba(18, 18, 28, 0.98) 0%, rgba(8, 8, 16, 0.99) 100%)',
borderColor: 'rgba(255, 255, 255, 0.08)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
cornerColor: 'rgba(255, 255, 255, 0.2)',
},
};
/**
* FUI 毛玻璃容器组件
* 带有科幻风格角落装饰的 Glassmorphism 容器
*
* @example
* ```tsx
* <FuiContainer variant="gold">
* <YourContent />
* </FuiContainer>
* ```
*/
const FuiContainer = memo<FuiContainerProps>(({
children,
showCorners = true,
cornersProps,
variant = 'default',
...boxProps
}) => {
const theme = VARIANTS[variant];
return (
<Box
position="relative"
bg={theme.bg}
borderRadius="xl"
border="1px solid"
borderColor={theme.borderColor}
overflow="hidden"
backdropFilter="blur(16px)"
boxShadow={theme.boxShadow}
{...boxProps}
>
{showCorners && (
<FuiCorners
borderColor={theme.cornerColor}
{...cornersProps}
/>
)}
{children}
</Box>
);
});
FuiContainer.displayName = 'FuiContainer';
export default FuiContainer;

View File

@@ -0,0 +1,126 @@
/**
* FUI 角落装饰组件
* Ash Thorp 风格的科幻 UI 角落装饰
*/
import React, { memo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
export interface FuiCornersProps {
/** 装饰框大小 */
size?: number;
/** 边框宽度 */
borderWidth?: number;
/** 边框颜色 */
borderColor?: string;
/** 透明度 */
opacity?: number;
/** 距离容器边缘的距离 */
offset?: number;
}
interface CornerBoxProps {
corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
size: number;
borderWidth: number;
borderColor: string;
opacity: number;
offset: number;
}
const CornerBox = memo<CornerBoxProps>(({
corner,
size,
borderWidth,
borderColor,
opacity,
offset,
}) => {
const cornerStyles: Record<string, BoxProps> = {
'top-left': {
top: `${offset}px`,
left: `${offset}px`,
borderTop: `${borderWidth}px solid`,
borderLeft: `${borderWidth}px solid`,
},
'top-right': {
top: `${offset}px`,
right: `${offset}px`,
borderTop: `${borderWidth}px solid`,
borderRight: `${borderWidth}px solid`,
},
'bottom-left': {
bottom: `${offset}px`,
left: `${offset}px`,
borderBottom: `${borderWidth}px solid`,
borderLeft: `${borderWidth}px solid`,
},
'bottom-right': {
bottom: `${offset}px`,
right: `${offset}px`,
borderBottom: `${borderWidth}px solid`,
borderRight: `${borderWidth}px solid`,
},
};
return (
<Box
position="absolute"
w={`${size}px`}
h={`${size}px`}
borderColor={borderColor}
opacity={opacity}
pointerEvents="none"
{...cornerStyles[corner]}
/>
);
});
CornerBox.displayName = 'CornerBox';
/**
* FUI 角落装饰组件
* 在容器四角添加科幻风格的装饰边框
*
* @example
* ```tsx
* <Box position="relative">
* <FuiCorners />
* {children}
* </Box>
* ```
*/
const FuiCorners = memo<FuiCornersProps>(({
size = 16,
borderWidth = 2,
borderColor = 'rgba(212, 175, 55, 0.4)',
opacity = 0.6,
offset = 12,
}) => {
const corners: CornerBoxProps['corner'][] = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
];
return (
<>
{corners.map((corner) => (
<CornerBox
key={corner}
corner={corner}
size={size}
borderWidth={borderWidth}
borderColor={borderColor}
opacity={opacity}
offset={offset}
/>
))}
</>
);
});
FuiCorners.displayName = 'FuiCorners';
export default FuiCorners;

View File

@@ -0,0 +1,20 @@
/**
* FUI (Futuristic UI) 组件集合
* Ash Thorp 风格的科幻 UI 组件
*
* 组件说明:
* - FuiCorners: 科幻角落装饰
* - FuiContainer: FUI 风格容器
* - AmbientGlow: 页面级环境光效果position: fixed
* - CardGlow: 卡片级装饰光效(相对定位,用于卡片内部)
*/
export { default as FuiCorners } from './FuiCorners';
export { default as FuiContainer } from './FuiContainer';
export { default as AmbientGlow } from './AmbientGlow';
export { default as CardGlow } from './CardGlow';
export type { FuiCornersProps } from './FuiCorners';
export type { FuiContainerProps } from './FuiContainer';
export type { AmbientGlowProps } from './AmbientGlow';
export type { CardGlowProps } from './CardGlow';

View File

@@ -0,0 +1,84 @@
/**
* FavoriteButton - 通用关注/收藏按钮组件(图标按钮)
*/
import React from 'react';
import { IconButton, Tooltip, Spinner } from '@chakra-ui/react';
import { Star } from 'lucide-react';
export interface FavoriteButtonProps {
/** 是否已关注 */
isFavorite: boolean;
/** 加载状态 */
isLoading?: boolean;
/** 点击回调 */
onClick: () => void;
/** 按钮大小 */
size?: 'sm' | 'md' | 'lg';
/** 颜色主题 */
colorScheme?: 'gold' | 'default';
/** 是否显示 tooltip */
showTooltip?: boolean;
}
// 颜色配置
const COLORS = {
gold: {
active: '#F4D03F', // 已关注 - 亮金色
inactive: '#C9A961', // 未关注 - 暗金色
hoverBg: 'whiteAlpha.100',
},
default: {
active: 'yellow.400',
inactive: 'gray.400',
hoverBg: 'gray.100',
},
};
const FavoriteButton: React.FC<FavoriteButtonProps> = ({
isFavorite,
isLoading = false,
onClick,
size = 'sm',
colorScheme = 'gold',
showTooltip = true,
}) => {
const colors = COLORS[colorScheme];
const currentColor = isFavorite ? colors.active : colors.inactive;
const label = isFavorite ? '取消关注' : '加入自选';
const iconButton = (
<IconButton
aria-label={label}
icon={
isLoading ? (
<Spinner size="sm" color={currentColor} />
) : (
<Star
size={size === 'sm' ? 18 : size === 'md' ? 20 : 24}
fill={isFavorite ? currentColor : 'none'}
stroke={currentColor}
/>
)
}
variant="ghost"
color={currentColor}
size={size}
onClick={onClick}
isDisabled={isLoading}
_hover={{ bg: colors.hoverBg }}
/>
);
if (showTooltip) {
return (
<Tooltip label={label} placement="top">
{iconButton}
</Tooltip>
);
}
return iconButton;
};
export default FavoriteButton;

38
src/components/GlassCard/index.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
/**
* GlassCard 组件类型声明
*/
import { BoxProps } from '@chakra-ui/react';
import React from 'react';
export interface GlassCardProps extends Omit<BoxProps, 'children'> {
/** 变体: 'default' | 'elevated' | 'subtle' | 'transparent' */
variant?: 'default' | 'elevated' | 'subtle' | 'transparent';
/** 是否启用悬停效果 */
hoverable?: boolean;
/** 是否启用发光效果 */
glowing?: boolean;
/** 是否显示角落装饰 */
cornerDecor?: boolean;
/** 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl' */
rounded?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
/** 内边距: 'none' | 'sm' | 'md' | 'lg' */
padding?: 'none' | 'sm' | 'md' | 'lg';
/** 子元素 */
children?: React.ReactNode;
}
export interface GlassTheme {
colors: {
gold: { 400: string; 500: string };
bg: { deep: string; primary: string; elevated: string; surface: string };
line: { subtle: string; default: string; emphasis: string };
};
blur: { sm: string; md: string; lg: string };
glow: { sm: string; md: string };
}
declare const GlassCard: React.ForwardRefExoticComponent<GlassCardProps & React.RefAttributes<HTMLDivElement>>;
export { GLASS_THEME } from './index';
export default GlassCard;

View File

@@ -0,0 +1,179 @@
/**
* GlassCard - 通用毛玻璃卡片组件
*
* 复用自 Company 页面的 Glassmorphism 风格
* 可在全局使用
*/
import React, { memo, forwardRef } from 'react';
import { Box } from '@chakra-ui/react';
// 主题配置
const GLASS_THEME = {
colors: {
gold: {
400: '#D4AF37',
500: '#B8960C',
},
bg: {
deep: '#0A0A14',
primary: '#0F0F1A',
elevated: '#1A1A2E',
surface: '#252540',
},
line: {
subtle: 'rgba(212, 175, 55, 0.1)',
default: 'rgba(212, 175, 55, 0.2)',
emphasis: 'rgba(212, 175, 55, 0.4)',
},
},
blur: {
sm: 'blur(8px)',
md: 'blur(16px)',
lg: 'blur(24px)',
},
glow: {
sm: '0 0 8px rgba(212, 175, 55, 0.3)',
md: '0 0 16px rgba(212, 175, 55, 0.4)',
},
};
// 变体样式
const VARIANTS = {
default: {
bg: `linear-gradient(135deg, ${GLASS_THEME.colors.bg.elevated} 0%, ${GLASS_THEME.colors.bg.primary} 100%)`,
border: `1px solid ${GLASS_THEME.colors.line.default}`,
backdropFilter: GLASS_THEME.blur.md,
},
elevated: {
bg: `linear-gradient(145deg, ${GLASS_THEME.colors.bg.surface} 0%, ${GLASS_THEME.colors.bg.elevated} 100%)`,
border: `1px solid ${GLASS_THEME.colors.line.emphasis}`,
backdropFilter: GLASS_THEME.blur.lg,
},
subtle: {
bg: 'rgba(212, 175, 55, 0.05)',
border: `1px solid ${GLASS_THEME.colors.line.subtle}`,
backdropFilter: GLASS_THEME.blur.sm,
},
transparent: {
bg: 'rgba(15, 15, 26, 0.8)',
border: `1px solid ${GLASS_THEME.colors.line.default}`,
backdropFilter: GLASS_THEME.blur.lg,
},
};
const ROUNDED_MAP = {
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
'2xl': '24px',
};
const PADDING_MAP = {
none: 0,
sm: 3,
md: 4,
lg: 6,
};
// 角落装饰
const CornerDecor = memo(({ position }) => {
const baseStyle = {
position: 'absolute',
width: '12px',
height: '12px',
borderColor: GLASS_THEME.colors.gold[400],
borderStyle: 'solid',
borderWidth: 0,
opacity: 0.6,
};
const positions = {
tl: { top: '8px', left: '8px', borderTopWidth: '2px', borderLeftWidth: '2px' },
tr: { top: '8px', right: '8px', borderTopWidth: '2px', borderRightWidth: '2px' },
bl: { bottom: '8px', left: '8px', borderBottomWidth: '2px', borderLeftWidth: '2px' },
br: { bottom: '8px', right: '8px', borderBottomWidth: '2px', borderRightWidth: '2px' },
};
return <Box sx={{ ...baseStyle, ...positions[position] }} />;
});
CornerDecor.displayName = 'CornerDecor';
/**
* GlassCard 组件
*
* @param {string} variant - 变体: 'default' | 'elevated' | 'subtle' | 'transparent'
* @param {boolean} hoverable - 是否启用悬停效果
* @param {boolean} glowing - 是否启用发光效果
* @param {boolean} cornerDecor - 是否显示角落装饰
* @param {string} rounded - 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
* @param {string} padding - 内边距: 'none' | 'sm' | 'md' | 'lg'
*/
const GlassCard = forwardRef(
(
{
children,
variant = 'default',
hoverable = true,
glowing = false,
cornerDecor = false,
rounded = 'lg',
padding = 'md',
...props
},
ref
) => {
const variantStyle = VARIANTS[variant] || VARIANTS.default;
return (
<Box
ref={ref}
position="relative"
bg={variantStyle.bg}
border={variantStyle.border}
borderRadius={ROUNDED_MAP[rounded]}
backdropFilter={variantStyle.backdropFilter}
p={PADDING_MAP[padding]}
transition="all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)"
overflow="hidden"
_hover={
hoverable
? {
borderColor: GLASS_THEME.colors.line.emphasis,
boxShadow: glowing ? GLASS_THEME.glow.md : GLASS_THEME.glow.sm,
transform: 'translateY(-2px)',
}
: undefined
}
sx={{
...(glowing && {
boxShadow: GLASS_THEME.glow.sm,
}),
}}
{...props}
>
{/* 角落装饰 */}
{cornerDecor && (
<>
<CornerDecor position="tl" />
<CornerDecor position="tr" />
<CornerDecor position="bl" />
<CornerDecor position="br" />
</>
)}
{/* 内容 */}
<Box position="relative" zIndex={1}>
{children}
</Box>
</Box>
);
}
);
GlassCard.displayName = 'GlassCard';
export default memo(GlassCard);
export { GLASS_THEME };

View File

@@ -0,0 +1,353 @@
/**
* GlobalSidebar - 全局右侧工具栏
*
* 可收起/展开的侧边栏,包含关注股票和事件动态
* 收起时点击图标显示悬浮弹窗
*/
import React from 'react';
import {
Box,
VStack,
Icon,
IconButton,
Badge,
Spinner,
Center,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverHeader,
PopoverCloseButton,
Text,
HStack,
Portal,
} from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
import { useAuth } from '@/contexts/AuthContext';
import { getEventDetailUrl } from '@/utils/idEncoder';
import { Z_INDEX, LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
import WatchSidebar from '@views/Profile/components/WatchSidebar';
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
/**
* 收起状态下的图标菜单(带悬浮弹窗)
*/
const CollapsedMenu = ({
watchlist,
realtimeQuotes,
followingEvents,
eventComments,
onToggle,
onStockClick,
onEventClick,
onCommentClick,
onAddStock,
onAddEvent,
onUnwatch,
onUnfollow,
}) => {
return (
<VStack spacing={4} py={4} align="center">
{/* 展开按钮 */}
<HStack spacing={1} w="100%" justify="center" cursor="pointer" onClick={onToggle} _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }} py={1} borderRadius="md">
<Icon as={ChevronLeft} boxSize={4} color="rgba(255, 255, 255, 0.6)" />
<Text fontSize="10px" color="rgba(255, 255, 255, 0.5)">
展开
</Text>
</HStack>
{/* 关注股票 - 悬浮弹窗 */}
<Popover placement="left-start" trigger="click" isLazy>
<PopoverTrigger>
<VStack
spacing={1}
align="center"
cursor="pointer"
p={2}
borderRadius="md"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
position="relative"
>
<Box position="relative">
<Icon as={BarChart2} boxSize={5} color="rgba(59, 130, 246, 0.9)" />
{watchlist.length > 0 && (
<Badge
position="absolute"
top="-4px"
right="-8px"
colorScheme="red"
fontSize="9px"
minW="16px"
h="16px"
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
>
{watchlist.length > 99 ? '99+' : watchlist.length}
</Badge>
)}
</Box>
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
关注股票
</Text>
</VStack>
</PopoverTrigger>
<Portal>
<PopoverContent
w="300px"
bg="rgba(26, 32, 44, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
_focus={{ outline: 'none' }}
>
<PopoverHeader
borderBottomColor="rgba(255, 255, 255, 0.1)"
py={2}
px={3}
>
<HStack spacing={2}>
<Icon as={BarChart2} boxSize={4} color="rgba(59, 130, 246, 0.9)" />
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
关注股票 ({watchlist.length})
</Text>
</HStack>
</PopoverHeader>
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
<PopoverBody p={2}>
<WatchlistPanel
watchlist={watchlist}
realtimeQuotes={realtimeQuotes}
onStockClick={onStockClick}
onAddStock={onAddStock}
onUnwatch={onUnwatch}
hideTitle={true}
/>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
{/* 事件动态 - 悬浮弹窗 */}
<Popover placement="left-start" trigger="click" isLazy>
<PopoverTrigger>
<VStack
spacing={1}
align="center"
cursor="pointer"
p={2}
borderRadius="md"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
position="relative"
>
<Box position="relative">
<Icon as={Star} boxSize={5} color="rgba(234, 179, 8, 0.9)" />
{followingEvents.length > 0 && (
<Badge
position="absolute"
top="-4px"
right="-8px"
colorScheme="yellow"
fontSize="9px"
minW="16px"
h="16px"
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
>
{followingEvents.length > 99 ? '99+' : followingEvents.length}
</Badge>
)}
</Box>
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
关注事件
</Text>
</VStack>
</PopoverTrigger>
<Portal>
<PopoverContent
w="300px"
bg="rgba(26, 32, 44, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
_focus={{ outline: 'none' }}
>
<PopoverHeader
borderBottomColor="rgba(255, 255, 255, 0.1)"
py={2}
px={3}
>
<HStack spacing={2}>
<Icon as={Star} boxSize={4} color="rgba(234, 179, 8, 0.9)" />
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
事件动态
</Text>
</HStack>
</PopoverHeader>
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
<PopoverBody p={2}>
<FollowingEventsPanel
events={followingEvents}
eventComments={eventComments}
onEventClick={onEventClick}
onCommentClick={onCommentClick}
onAddEvent={onAddEvent}
onUnfollow={onUnfollow}
hideTitle={true}
/>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
{/* 热门板块 - 悬浮弹窗 */}
<Popover placement="left-start" trigger="click" isLazy>
<PopoverTrigger>
<VStack
spacing={1}
align="center"
cursor="pointer"
p={2}
borderRadius="md"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
position="relative"
>
<Icon as={TrendingUp} boxSize={5} color="rgba(34, 197, 94, 0.9)" />
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
热门板块
</Text>
</VStack>
</PopoverTrigger>
<Portal>
<PopoverContent
w="280px"
bg="rgba(26, 32, 44, 0.95)"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
_focus={{ outline: 'none' }}
>
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
<PopoverBody p={2}>
<HotSectorsRanking title="热门板块" />
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</VStack>
);
};
/**
* GlobalSidebar 主组件
*/
const GlobalSidebar = () => {
const { user } = useAuth();
const navigate = useNavigate();
const {
isOpen,
toggle,
watchlist,
realtimeQuotes,
followingEvents,
eventComments,
loading,
unwatchStock,
unfollowEvent,
} = useGlobalSidebar();
// 未登录时不显示
if (!user) {
return null;
}
return (
<Box
w={isOpen ? '300px' : '72px'}
h="100%"
pt={LAYOUT_SIZE.navbarHeight}
flexShrink={0}
transition="width 0.2s ease-in-out"
display={{ base: 'none', md: 'block' }}
bg="rgba(26, 32, 44, 0.98)"
borderLeft="1px solid rgba(255, 255, 255, 0.08)"
position="relative"
zIndex={Z_INDEX.SIDEBAR}
>
{/* 加载状态 */}
{loading && (
<Center position="absolute" top={4} left={0} right={0} zIndex={1}>
<Spinner size="sm" color="rgba(212, 175, 55, 0.6)" />
</Center>
)}
{isOpen ? (
/* 展开状态 */
<Box h="100%" display="flex" flexDirection="column">
{/* 标题栏 - 收起按钮 + 标题 */}
<HStack
px={3}
py={3}
bg="rgba(26, 32, 44, 1)"
borderBottom="1px solid rgba(255, 255, 255, 0.1)"
flexShrink={0}
>
<IconButton
icon={<Icon as={ChevronRight} />}
size="xs"
variant="ghost"
color="rgba(255, 255, 255, 0.5)"
_hover={{ color: 'rgba(212, 175, 55, 0.9)', bg: 'rgba(255, 255, 255, 0.05)' }}
onClick={toggle}
aria-label="收起工具栏"
/>
<Text fontSize="sm" fontWeight="medium" color="rgba(255, 255, 255, 0.7)">
工具栏
</Text>
</HStack>
{/* WatchSidebar 内容 */}
<Box flex="1" overflowY="auto" pt={2} px={2}>
<WatchSidebar
watchlist={watchlist}
realtimeQuotes={realtimeQuotes}
followingEvents={followingEvents}
eventComments={eventComments}
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
onAddStock={() => navigate('/stocks')}
onAddEvent={() => navigate('/community')}
onUnwatch={unwatchStock}
onUnfollow={unfollowEvent}
/>
</Box>
</Box>
) : (
/* 收起状态 - 点击图标显示悬浮弹窗 */
<CollapsedMenu
watchlist={watchlist}
realtimeQuotes={realtimeQuotes}
followingEvents={followingEvents}
eventComments={eventComments}
onToggle={toggle}
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
onAddStock={() => navigate('/stocks')}
onAddEvent={() => navigate('/community')}
onUnwatch={unwatchStock}
onUnfollow={unfollowEvent}
/>
)}
</Box>
);
};
export default GlobalSidebar;

View File

@@ -545,19 +545,13 @@ const InvestmentCalendar = () => {
render: (concepts) => (
<Space wrap>
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => {
// 兼容多种数据格式:字符串、数组、对象
const conceptName = typeof concept === 'string'
? concept
: Array.isArray(concept)
? concept[0]
: concept?.concept || concept?.name || '';
return (
<Tag key={index} icon={<TagsOutlined />}>
{conceptName}
</Tag>
);
})
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{typeof concept === 'string'
? concept
: (concept?.concept || concept?.name || '未知')}
</Tag>
))
) : (
<Text type="secondary"></Text>
)}
@@ -949,7 +943,7 @@ const InvestmentCalendar = () => {
<Table
dataSource={selectedStocks}
columns={stockColumns}
rowKey={(record) => record[0]}
rowKey={(record) => record.code}
size="middle"
pagination={false}
/>

View File

@@ -1,45 +1,187 @@
import React from "react";
// src/components/Navbars/SearchBar/SearchBar.js
// 全局股票搜索栏 - 模糊搜索 + 下拉选择
import React, { useRef, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
IconButton,
Box,
Input,
InputGroup,
InputLeftElement,
useColorModeValue,
InputRightElement,
IconButton,
Text,
VStack,
HStack,
Spinner,
Tag,
Center,
List,
ListItem,
Flex,
} from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import { SearchIcon, CloseIcon } from "@chakra-ui/icons";
import { useStockSearch } from "@hooks/useStockSearch";
export function SearchBar(props) {
// Pass the computed styles into the `__css` prop
const { variant, children, ...rest } = props;
// Chakra Color Mode
const searchIconColor = useColorModeValue("gray.700", "gray.200");
const inputBg = useColorModeValue("white", "navy.800");
const navigate = useNavigate();
const containerRef = useRef(null);
// 颜色配置 - 固定使用深色主题
const searchIconColor = "gray.400";
const inputBg = "whiteAlpha.100";
const dropdownBg = "#1a1a2e";
const borderColor = "rgba(212, 175, 55, 0.3)";
const hoverBg = "whiteAlpha.100";
const textColor = "white";
const subTextColor = "whiteAlpha.600";
const accentColor = "#D4AF37";
// 使用搜索 Hook
const {
searchQuery,
searchResults,
isSearching,
showResults,
handleSearch,
clearSearch,
setShowResults,
} = useStockSearch({ limit: 10, debounceMs: 300 });
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [setShowResults]);
// 选择股票 - 跳转到详情页
const handleSelectStock = useCallback((stock) => {
clearSearch();
// 跳转到股票详情页
navigate(`/company/${stock.stock_code}`);
}, [navigate, clearSearch]);
// 处理键盘事件
const handleKeyDown = useCallback((e) => {
if (e.key === "Enter" && searchResults.length > 0) {
handleSelectStock(searchResults[0]);
} else if (e.key === "Escape") {
setShowResults(false);
}
}, [searchResults, handleSelectStock, setShowResults]);
return (
<InputGroup borderRadius='8px' w='200px' {...rest}>
<InputLeftElement
children={
<IconButton
bg='inherit'
borderRadius='inherit'
_hover={{}}
_active={{
bg: "inherit",
transform: "none",
borderColor: "transparent",
}}
_focus={{
boxShadow: "none",
}}
icon={
<SearchIcon color={searchIconColor} w='15px' h='15px' />
}></IconButton>
}
/>
<Input
variant='search'
fontSize='xs'
bg={inputBg}
placeholder='Type here...'
/>
</InputGroup>
<Box ref={containerRef} position="relative" {...rest}>
<InputGroup borderRadius="8px" w="220px">
<InputLeftElement pointerEvents="none">
<SearchIcon color={searchIconColor} w="15px" h="15px" />
</InputLeftElement>
<Input
variant="search"
fontSize="sm"
bg={inputBg}
placeholder="搜索股票..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => searchQuery && searchResults.length > 0 && setShowResults(true)}
borderColor={borderColor}
_hover={{ borderColor: accentColor }}
_focus={{ borderColor: accentColor, boxShadow: `0 0 0 1px ${accentColor}` }}
/>
{(searchQuery || isSearching) && (
<InputRightElement>
{isSearching ? (
<Spinner size="sm" color={accentColor} />
) : (
<IconButton
size="xs"
variant="ghost"
icon={<CloseIcon w="10px" h="10px" />}
onClick={clearSearch}
aria-label="清除搜索"
_hover={{ bg: "transparent" }}
/>
)}
</InputRightElement>
)}
</InputGroup>
{/* 搜索结果下拉 */}
{showResults && (
<Box
position="absolute"
top="100%"
left={0}
mt={2}
w="320px"
bg={dropdownBg}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
boxShadow="lg"
maxH="400px"
overflowY="auto"
zIndex={9999}
>
{searchResults.length > 0 ? (
<List spacing={0}>
{searchResults.map((stock, index) => (
<ListItem
key={stock.stock_code}
px={4}
py={3}
cursor="pointer"
_hover={{ bg: hoverBg }}
onClick={() => handleSelectStock(stock)}
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
borderColor={borderColor}
>
<Flex align="center" justify="space-between">
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="bold" color={textColor} fontSize="sm">
{stock.stock_name}
</Text>
<HStack spacing={2}>
<Text fontSize="xs" color={subTextColor}>
{stock.stock_code}
</Text>
{stock.pinyin_abbr && (
<Text fontSize="xs" color={subTextColor}>
({stock.pinyin_abbr.toUpperCase()})
</Text>
)}
</HStack>
</VStack>
{stock.exchange && (
<Tag
size="sm"
colorScheme="blue"
variant="subtle"
fontSize="xs"
>
{stock.exchange}
</Tag>
)}
</Flex>
</ListItem>
))}
</List>
) : (
<Center p={4}>
<Text color={subTextColor} fontSize="sm">
{searchQuery ? "未找到相关股票" : "输入股票代码或名称搜索"}
</Text>
</Center>
)}
</Box>
)}
</Box>
);
}

View File

@@ -1,7 +1,7 @@
// src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js
// 关注事件下拉菜单组件
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import {
Menu,
MenuButton,
@@ -22,6 +22,7 @@ import { FiCalendar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
import { getEventDetailUrl } from '@/utils/idEncoder';
import FavoriteButton from '@/components/FavoriteButton';
/**
* 关注事件下拉菜单组件
@@ -30,6 +31,7 @@ import { getEventDetailUrl } from '@/utils/idEncoder';
*/
const FollowingEventsMenu = memo(() => {
const navigate = useNavigate();
const [unfollowingId, setUnfollowingId] = useState(null);
const {
followingEvents,
eventsLoading,
@@ -40,6 +42,17 @@ const FollowingEventsMenu = memo(() => {
handleUnfollowEvent
} = useFollowingEvents();
// 处理取消关注(带 loading 状态)
const handleUnfollow = async (eventId) => {
if (unfollowingId) return;
setUnfollowingId(eventId);
try {
await handleUnfollowEvent(eventId);
} finally {
setUnfollowingId(null);
}
};
const titleColor = useColorModeValue('gray.600', 'gray.300');
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -108,27 +121,6 @@ const FollowingEventsMenu = memo(() => {
</HStack>
</Box>
<HStack flexShrink={0} spacing={1}>
{/* 热度 */}
{typeof ev.hot_score === 'number' && (
<Badge
colorScheme={
ev.hot_score >= 80 ? 'red' :
(ev.hot_score >= 60 ? 'orange' : 'gray')
}
fontSize="xs"
>
🔥 {ev.hot_score}
</Badge>
)}
{/* 关注数 */}
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
<Badge
colorScheme="purple"
fontSize="xs"
>
👥 {ev.follower_count}
</Badge>
)}
{/* 日均涨跌幅 */}
{typeof ev.related_avg_chg === 'number' && (
<Badge
@@ -155,23 +147,21 @@ const FollowingEventsMenu = memo(() => {
{ev.related_week_chg.toFixed(2)}%
</Badge>
)}
{/* 取消关注按钮 */}
{/* 取消关注按钮 - 使用 FavoriteButton */}
<Box
as="span"
fontSize="xs"
color="red.500"
cursor="pointer"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: 'red.50' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleUnfollowEvent(ev.id);
}}
>
取消
<FavoriteButton
isFavorite={true}
isLoading={unfollowingId === ev.id}
onClick={() => handleUnfollow(ev.id)}
size="sm"
colorScheme="gold"
showTooltip={true}
/>
</Box>
</HStack>
</HStack>

View File

@@ -1,7 +1,7 @@
// src/components/Navbars/components/FeatureMenus/WatchlistMenu.js
// 自选股下拉菜单组件
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import {
Menu,
MenuButton,
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
import { FiStar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
import { useWatchlist } from '../../../../hooks/useWatchlist';
import FavoriteButton from '@/components/FavoriteButton';
/**
* 自选股下拉菜单组件
@@ -29,6 +30,7 @@ import { useWatchlist } from '../../../../hooks/useWatchlist';
*/
const WatchlistMenu = memo(() => {
const navigate = useNavigate();
const [removingCode, setRemovingCode] = useState(null);
const {
watchlistQuotes,
watchlistLoading,
@@ -39,6 +41,17 @@ const WatchlistMenu = memo(() => {
handleRemoveFromWatchlist
} = useWatchlist();
// 处理取消关注(带 loading 状态)
const handleUnwatch = async (stockCode) => {
if (removingCode) return;
setRemovingCode(stockCode);
try {
await handleRemoveFromWatchlist(stockCode);
} finally {
setRemovingCode(null);
}
};
const titleColor = useColorModeValue('gray.600', 'gray.300');
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -114,21 +127,19 @@ const WatchlistMenu = memo(() => {
(item.current_price || '-')}
</Text>
<Box
as="span"
fontSize="xs"
color="red.500"
cursor="pointer"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: 'red.50' }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFromWatchlist(item.stock_code);
}}
>
取消
<FavoriteButton
isFavorite={true}
isLoading={removingCode === item.stock_code}
onClick={() => handleUnwatch(item.stock_code)}
size="sm"
colorScheme="gold"
showTooltip={true}
/>
</Box>
</HStack>
</HStack>

View File

@@ -2,7 +2,8 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import * as echarts from 'echarts';
import { echarts } from '@lib/echarts';
import type { ECharts, EChartsOption } from '@lib/echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice';
@@ -295,7 +296,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
const option: EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,

View File

@@ -1,594 +0,0 @@
// src/components/StockChart/StockChartAntdModal.js - Antd版本的股票图表组件
import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Spin, Typography } from 'antd';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent';
import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer';
const { Text } = Typography;
const StockChartAntdModal = ({
open = false,
onCancel,
stock,
eventTime,
fixed = false,
width = 800
}) => {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);
const [activeChartType, setActiveChartType] = useState('timeline');
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState(null);
const [preloadedData, setPreloadedData] = useState({});
// 预加载数据
const preloadData = async (type) => {
if (!stock?.stock_code || preloadedData[type]) return;
try {
// 统一的事件时间处理逻辑:盘后事件推到次日开盘
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
}
} catch (e) {
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData(prev => ({...prev, [type]: response}));
logger.debug('StockChartAntdModal', '数据预加载成功', {
stockCode: stock.stock_code,
type,
dataLength: response?.data?.length || 0
});
} catch (err) {
logger.error('StockChartAntdModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type
});
}
};
// 预加载数据的effect
useEffect(() => {
if (open && stock?.stock_code) {
// 预加载两种图表类型的数据
preloadData('timeline');
preloadData('daily');
}
}, [open, stock?.stock_code, eventTime]);
// 加载图表数据
useEffect(() => {
const loadChartData = async () => {
if (!stock?.stock_code) return;
try {
setLoading(true);
// 先尝试使用预加载的数据
let data = preloadedData[activeChartType];
if (!data) {
// 如果预加载数据不存在,则立即请求
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
}
} catch (e) {
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
data = await stockService.getKlineData(stock.stock_code, activeChartType, adjustedEventTime);
}
setChartData(data);
logger.debug('StockChartAntdModal', '图表数据加载成功', {
stockCode: stock.stock_code,
chartType: activeChartType,
dataLength: data?.data?.length || 0
});
} catch (error) {
logger.error('StockChartAntdModal', 'loadChartData', error, {
stockCode: stock?.stock_code,
chartType: activeChartType
});
} finally {
setLoading(false);
}
};
if (stock && stock.stock_code) {
loadChartData();
}
}, [stock?.stock_code, activeChartType, eventTime]);
// 生成图表配置
const getChartOption = () => {
if (!chartData || !chartData.data) {
return {
title: { text: '暂无数据', left: 'center' },
xAxis: { type: 'category', data: [] },
yAxis: { type: 'value' },
series: [{ data: [], type: 'line' }]
};
}
const data = chartData.data;
const tradeDate = chartData.trade_date;
// 处理数据格式
let times = [];
let prices = [];
let opens = [];
let highs = [];
let lows = [];
let closes = [];
let volumes = [];
if (Array.isArray(data)) {
times = data.map(item => item.time || item.date || item.timestamp);
prices = data.map(item => item.close || item.price || item.value);
opens = data.map(item => item.open);
highs = data.map(item => item.high);
lows = data.map(item => item.low);
closes = data.map(item => item.close);
volumes = data.map(item => item.volume);
} else if (data.times && data.prices) {
times = data.times;
prices = data.prices;
opens = data.opens || [];
highs = data.highs || [];
lows = data.lows || [];
closes = data.closes || [];
volumes = data.volumes || [];
}
// 生成K线数据结构
const klineData = times.map((t, i) => [opens[i], closes[i], lows[i], highs[i]]);
// 计算事件标记线位置
let markLineData = [];
if (eventTime && times.length > 0) {
const eventMoment = dayjs(eventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
if (activeChartType === 'timeline') {
// 分时图:在相同交易日内定位具体时间
if (eventDate === tradeDate) {
const eventTime = eventMoment.format('HH:mm');
let nearestIdx = 0;
const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute();
for (let i = 0; i < times.length; i++) {
const [h, m] = times[i].split(':').map(Number);
const timeMinutes = h * 60 + m;
const currentDiff = Math.abs(timeMinutes - eventMinutes);
const nearestDiff = Math.abs(
(times[nearestIdx].split(':').map(Number)[0] * 60 + times[nearestIdx].split(':').map(Number)[1]) - eventMinutes
);
if (currentDiff < nearestDiff) {
nearestIdx = i;
}
}
markLineData = [{
name: '事件发生',
xAxis: nearestIdx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#FFD700',
fontSize: 12
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2
}
}];
}
} else if (activeChartType === 'daily') {
// 日K线定位到交易日
let targetIndex = -1;
// 1. 先尝试找到完全匹配的日期
targetIndex = times.findIndex(time => time === eventDate);
// 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日
if (targetIndex === -1) {
for (let i = 0; i < times.length; i++) {
if (times[i] >= eventDate) {
targetIndex = i;
break;
}
}
}
// 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日
if (targetIndex === -1 && eventDate > times[times.length - 1]) {
targetIndex = times.length - 1;
}
// 4. 如果事件日期早于所有交易日,则标记在第一个交易日
if (targetIndex === -1 && eventDate < times[0]) {
targetIndex = 0;
}
if (targetIndex >= 0) {
let labelText = '事件发生';
let labelPosition = 'middle';
// 根据事件时间和交易日的关系调整标签
if (eventDate === times[targetIndex]) {
if (eventMoment.hour() >= 15) {
labelText = '事件发生\n(盘后)';
} else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) {
labelText = '事件发生\n(盘前)';
}
} else if (eventDate < times[targetIndex]) {
labelText = '事件发生\n(前一日)';
labelPosition = 'start';
} else {
labelText = '事件发生\n(影响日)';
labelPosition = 'end';
}
markLineData = [{
name: '事件发生',
xAxis: targetIndex,
label: {
formatter: labelText,
position: labelPosition,
color: '#FFD700',
fontSize: 12,
backgroundColor: 'rgba(0,0,0,0.5)',
padding: [4, 8],
borderRadius: 4
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2
}
}];
}
}
}
// 分时图
if (activeChartType === 'timeline') {
const avgPrices = data.map(item => item.avg_price);
// 获取昨收盘价作为基准
const prevClose = chartData.prev_close || (prices.length > 0 ? prices[0] : 0);
// 计算涨跌幅数据
const changePercentData = prices.map(price => ((price - prevClose) / prevClose * 100));
const avgChangePercentData = avgPrices.map(avgPrice => ((avgPrice - prevClose) / prevClose * 100));
const currentPrice = prices[prices.length - 1];
const currentChange = ((currentPrice - prevClose) / prevClose * 100);
const isUp = currentChange >= 0;
const lineColor = isUp ? '#ef5350' : '#26a69a';
return {
title: {
text: `${stock.stock_name || stock.stock_code} - 分时图`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function(params) {
const d = params[0]?.dataIndex ?? 0;
const price = prices[d];
const avgPrice = avgPrices[d];
const volume = volumes[d];
// 安全计算涨跌幅,处理 undefined/null/0 的情况
const safeCalcPercent = (val, base) => {
if (val == null || base == null || base === 0) return 0;
return ((val - base) / base * 100);
};
const priceChangePercent = safeCalcPercent(price, prevClose);
const avgChangePercent = safeCalcPercent(avgPrice, prevClose);
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
// 安全格式化数字
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
const formatPercent = (val) => {
if (val == null || isNaN(val)) return '-';
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%';
};
return `时间:${times[d] || '-'}<br/>现价:<span style="color: ${priceColor}">¥${safeFixed(price)} (${formatPercent(priceChangePercent)})</span><br/>均价:<span style="color: ${avgColor}">¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})</span><br/>昨收:¥${safeFixed(prevClose)}<br/>成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`;
}
},
grid: [
{ left: '10%', right: '10%', height: '50%', top: '15%' },
{ left: '10%', right: '10%', top: '70%', height: '20%' }
],
xAxis: [
{ type: 'category', data: times, gridIndex: 0, boundaryGap: false },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } }
],
yAxis: [
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'left',
axisLabel: {
formatter: function(value) {
if (value == null || isNaN(value)) return '-';
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}
},
splitLine: {
show: true,
lineStyle: {
color: '#f0f0f0'
}
}
},
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'right',
axisLabel: {
formatter: function(value) {
if (value == null || isNaN(value)) return '-';
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}
}
},
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? Math.round(v/100) + '手' : '-' } }
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' }
],
series: [
{
name: '分时价',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: changePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' },
{ offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' }
])
},
markLine: {
symbol: 'none',
data: [
// 昨收盘价基准线 (0%)
{
yAxis: 0,
lineStyle: {
color: '#666',
type: 'dashed',
width: 1.5,
opacity: 0.8
},
label: {
show: true,
formatter: '昨收盘价',
position: 'insideEndTop',
color: '#666',
fontSize: 12
}
},
...markLineData
],
animation: false
}
},
{
name: '均价线',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 1,
data: avgChangePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#FFA500', width: 1 }
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 2,
data: volumes,
itemStyle: { color: '#b0c4de', opacity: 0.6 }
}
]
};
}
// 日K线图
if (activeChartType === 'daily') {
return {
title: {
text: `${stock.stock_name || stock.stock_code} - 日K线`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function(params) {
const kline = params[0];
const volume = params[1];
if (!kline || !kline.data) return '';
let tooltipHtml = `日期: ${times[kline.dataIndex]}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
if (volume && volume.data) {
tooltipHtml += `<br/>成交量: ${Math.round(volume.data/100)}`;
}
return tooltipHtml;
}
},
grid: [
{ left: '10%', right: '10%', height: '60%' },
{ left: '10%', right: '10%', top: '75%', height: '20%' }
],
xAxis: [
{ type: 'category', data: times, scale: true, boundaryGap: true, gridIndex: 0 },
{ type: 'category', gridIndex: 1, data: times, axisLabel: { show: false } }
],
yAxis: [
{ scale: true, splitArea: { show: true }, gridIndex: 0 },
{ scale: true, gridIndex: 1, axisLabel: { formatter: (value) => Math.round(value/100) + '手' } }
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 }
],
series: [
{
name: 'K线',
type: 'candlestick',
yAxisIndex: 0,
data: klineData,
markLine: {
symbol: 'none',
data: markLineData,
animation: false
},
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a'
}
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes.map((volume, index) => ({
value: volume,
itemStyle: {
color: closes[index] >= opens[index] ? '#ef5350' : '#26a69a'
}
}))
}
]
};
}
};
return (
<Modal
open={open}
title={`${stock?.stock_name || stock?.stock_code} (${stock?.stock_code}) - 股票详情`}
footer={null}
onCancel={onCancel}
width={width}
centered
zIndex={2500}
mask={true}
destroyOnHidden={true}
bodyStyle={{ maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', padding: '16px' }}
>
<div style={{ width: '100%' }}>
{/* 图表类型切换按钮 */}
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<Button
type={activeChartType === 'timeline' ? 'primary' : 'default'}
onClick={() => setActiveChartType('timeline')}
>
分时图
</Button>
<Button
type={activeChartType === 'daily' ? 'primary' : 'default'}
onClick={() => setActiveChartType('daily')}
>
日K线
</Button>
</div>
{/* 图表容器 */}
<div style={{ height: '400px', width: '100%' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
) : (
<ReactECharts
option={getChartOption()}
style={{ height: '100%', width: '100%' }}
notMerge={true}
lazyUpdate={true}
onChartReady={(chart) => {
setTimeout(() => chart.resize(), 50);
}}
/>
)}
</div>
{/* 关联描述 */}
{stock?.relation_desc?.data ? (
// 使用引用组件(带研报来源)
<CitedContent
data={stock.relation_desc}
title="关联描述"
containerStyle={{ marginTop: 16 }}
/>
) : stock?.relation_desc ? (
// 降级显示(无引用数据)
<div style={{ marginTop: 16, padding: 16, backgroundColor: '#f5f5f5', borderRadius: 6 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>关联描述:</Text>
<Text>{stock.relation_desc}AI合成</Text>
</div>
) : null}
{/* 风险提示 */}
<RiskDisclaimer variant="default" />
</div>
</Modal>
);
};
export default StockChartAntdModal;

View File

@@ -17,7 +17,7 @@ import {
Alert,
AlertIcon,
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice';
@@ -186,7 +186,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
}
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
const option: EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,

View File

@@ -0,0 +1,403 @@
/**
* SubTabContainer - 二级导航容器组件
*
* 深空 FUI 设计风格Glassmorphism + Ash Thorp + James Turrell
* - 玻璃态导航栏,漂浮感
* - 选中态发光效果,科幻数据终端感
* - 流畅的过渡动画
*
* @example
* ```tsx
* <SubTabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
* ]}
* componentProps={{ stockCode: '000001' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
* ```
*/
import React, { useState, useCallback, memo, Suspense } from 'react';
import {
Box,
Flex,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
HStack,
Text,
Center,
Spinner,
} from '@chakra-ui/react';
import type { ComponentType } from 'react';
import type { IconType } from 'react-icons';
/**
* Tab 配置项
*/
export interface SubTabConfig {
key: string;
name: string;
icon?: IconType | ComponentType;
component?: ComponentType<any>;
/** 自定义 Suspense fallback如骨架屏 */
fallback?: React.ReactNode;
}
/**
* 深空 FUI 主题配置
*/
const DEEP_SPACE = {
// 背景
bgGlass: 'rgba(12, 14, 28, 0.6)',
bgGlassHover: 'rgba(18, 22, 42, 0.7)',
// 边框
borderGold: 'rgba(212, 175, 55, 0.2)',
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
borderGlass: 'rgba(255, 255, 255, 0.06)',
// 发光
glowGold: '0 0 30px rgba(212, 175, 55, 0.25), 0 4px 20px rgba(0, 0, 0, 0.3)',
innerGlow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
// 文字
textWhite: 'rgba(255, 255, 255, 0.95)',
textMuted: 'rgba(255, 255, 255, 0.6)',
textGold: '#F4D03F',
textDark: '#0A0A14',
// 选中态
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
// 圆角
radius: '12px',
radiusLG: '16px',
// 动画
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
};
/**
* 主题配置
*/
export interface SubTabTheme {
bg: string;
borderColor: string;
tabSelectedBg: string;
tabSelectedColor: string;
tabUnselectedColor: string;
tabHoverBg: string;
}
/**
* 尺寸配置
*/
const SIZE_CONFIG = {
sm: { fontSize: '13px', px: 4, py: 2, gap: 1.5, iconSize: 3.5 },
md: { fontSize: '15px', px: 6, py: 3, gap: 2, iconSize: 4 },
} as const;
export type SubTabSize = keyof typeof SIZE_CONFIG;
/**
* 预设主题 - 深空 FUI 风格
*/
const THEME_PRESETS: Record<string, SubTabTheme> = {
blackGold: {
bg: DEEP_SPACE.bgGlass,
borderColor: DEEP_SPACE.borderGold,
tabSelectedBg: DEEP_SPACE.selectedBg,
tabSelectedColor: DEEP_SPACE.textDark,
tabUnselectedColor: DEEP_SPACE.textWhite,
tabHoverBg: DEEP_SPACE.bgGlassHover,
},
default: {
bg: 'white',
borderColor: 'gray.200',
tabSelectedBg: 'blue.500',
tabSelectedColor: 'white',
tabUnselectedColor: 'gray.600',
tabHoverBg: 'gray.100',
},
};
export interface SubTabContainerProps {
/** Tab 配置数组 */
tabs: SubTabConfig[];
/** 传递给 Tab 内容组件的 props */
componentProps?: Record<string, any>;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string) => void;
/** 主题预设 */
themePreset?: 'blackGold' | 'default';
/** 自定义主题(优先级高于预设) */
theme?: Partial<SubTabTheme>;
/** 内容区内边距 */
contentPadding?: number;
/** 是否懒加载 */
isLazy?: boolean;
/** TabList 右侧自定义内容 */
rightElement?: React.ReactNode;
/** 紧凑模式 - 移除 TabList 的外边距 */
compact?: boolean;
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
size?: SubTabSize;
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
tabs,
componentProps = {},
defaultIndex = 0,
index: controlledIndex,
onTabChange,
themePreset = 'blackGold',
theme: customTheme,
contentPadding = 4,
isLazy = true,
rightElement,
compact = false,
size = 'md',
}) => {
// 获取尺寸配置
const sizeConfig = SIZE_CONFIG[size];
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
// 当前索引
const currentIndex = controlledIndex ?? internalIndex;
// 记录已访问的 Tab 索引(用于真正的懒加载)
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
() => new Set([controlledIndex ?? defaultIndex])
);
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
() => ({ [controlledIndex ?? defaultIndex]: 1 })
);
// 合并主题
const theme: SubTabTheme = {
...THEME_PRESETS[themePreset],
...customTheme,
};
/**
* 处理 Tab 切换
*/
const handleTabChange = useCallback(
(newIndex: number) => {
// 保存当前滚动位置,防止 Tab 切换时页面跳转
const scrollY = window.scrollY;
const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey);
// 记录已访问的 Tab用于懒加载
setVisitedTabs(prev => {
if (prev.has(newIndex)) return prev;
return new Set(prev).add(newIndex);
});
// 更新激活计数(用于触发特定 Tab 的数据刷新)
setActivationCounts(prev => ({
...prev,
[newIndex]: (prev[newIndex] || 0) + 1,
}));
if (controlledIndex === undefined) {
setInternalIndex(newIndex);
}
// 恢复滚动位置,阻止浏览器自动滚动
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
},
[tabs, onTabChange, controlledIndex]
);
return (
<Box>
<Tabs
isLazy={isLazy}
lazyBehavior="keepMounted"
variant="unstyled"
index={currentIndex}
onChange={handleTabChange}
>
{/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
<Flex
bg={theme.bg}
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor={theme.borderColor}
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
mx={compact ? 0 : 2}
mb={compact ? 0 : 2}
position="relative"
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
alignItems="center"
>
{/* 顶部金色光条 */}
<Box
position="absolute"
top={0}
left="50%"
transform="translateX(-50%)"
width="50%"
height="1px"
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
/>
{/* 左侧:可滚动的 Tab 区域 */}
<Box
flex="1"
minW={0}
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<TabList
border="none"
px={3}
py={compact ? 2 : sizeConfig.py}
flexWrap="nowrap"
gap={sizeConfig.gap}
>
{tabs.map((tab, idx) => {
const isSelected = idx === currentIndex;
return (
<Tab
key={tab.key}
color={theme.tabUnselectedColor}
borderRadius={DEEP_SPACE.radius}
px={sizeConfig.px}
py={sizeConfig.py}
fontSize={sizeConfig.fontSize}
fontWeight="500"
whiteSpace="nowrap"
flexShrink={0}
border="1px solid transparent"
position="relative"
letterSpacing="0.03em"
transition={DEEP_SPACE.transition}
_before={{
content: '""',
position: 'absolute',
bottom: '-1px',
left: '50%',
transform: 'translateX(-50%)',
width: isSelected ? '70%' : '0%',
height: '2px',
bg: '#D4AF37',
borderRadius: 'full',
transition: 'width 0.3s ease',
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
}}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: '700',
boxShadow: DEEP_SPACE.glowGold,
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
transform: 'translateY(-2px)',
}}
_hover={{
bg: isSelected ? undefined : theme.tabHoverBg,
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
transform: 'translateY(-1px)',
}}
_active={{
transform: 'translateY(0)',
}}
>
<HStack spacing={size === 'sm' ? 1.5 : 2}>
{tab.icon && (
<Icon
as={tab.icon}
boxSize={sizeConfig.iconSize}
opacity={isSelected ? 1 : 0.7}
transition="opacity 0.2s"
/>
)}
<Text>{tab.name}</Text>
</HStack>
</Tab>
);
})}
</TabList>
</Box>
{/* 右侧:固定的自定义元素(如期数选择器) */}
{rightElement && (
<Box
flexShrink={0}
pr={3}
pl={2}
py={compact ? 2 : sizeConfig.py}
borderLeft="1px solid"
borderColor={DEEP_SPACE.borderGold}
>
{rightElement}
</Box>
)}
</Flex>
<TabPanels p={contentPadding}>
{tabs.map((tab, idx) => {
const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
// 判断是否为当前激活的 Tab用于控制数据加载
const isActive = idx === currentIndex;
return (
<TabPanel key={tab.key} p={0}>
{shouldRender && Component ? (
<Suspense
fallback={
tab.fallback || (
<Center py={20}>
<Spinner
size="lg"
color={DEEP_SPACE.textGold}
thickness="3px"
speed="0.8s"
/>
</Center>
)
}
>
<Component
{...componentProps}
isActive={isActive}
activationKey={activationCounts[idx] || 0}
/>
</Suspense>
) : null}
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</Box>
);
});
SubTabContainer.displayName = 'SubTabContainer';
export default SubTabContainer;

View File

@@ -1632,14 +1632,17 @@ export default function SubscriptionContentNew() {
<Text fontSize="sm" color="rgba(255, 255, 255, 0.7)">
<ChakraLink
href={AGREEMENT_URLS[(selectedPlan as any)?.name?.toLowerCase()] || AGREEMENT_URLS.pro}
href={(() => {
const planName = (selectedPlan as { name?: string } | null)?.name?.toLowerCase();
return planName === 'pro' || planName === 'max' ? AGREEMENT_URLS[planName] : AGREEMENT_URLS.pro;
})()}
isExternal
color="#3182CE"
textDecoration="underline"
mx={1}
onClick={(e) => e.stopPropagation()}
>
{(selectedPlan as any)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}
{(selectedPlan as { name?: string } | null)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}
</ChakraLink>
</Text>
</Checkbox>

View File

@@ -0,0 +1,56 @@
/**
* TabNavigation 通用导航组件
*
* 渲染 Tab 按钮列表,支持图标 + 文字
*/
import React from 'react';
import { TabList, Tab, HStack, Icon, Text } from '@chakra-ui/react';
import type { TabNavigationProps } from './types';
const TabNavigation: React.FC<TabNavigationProps> = ({
tabs,
themeColors,
borderRadius = 'lg',
}) => {
return (
<TabList
bg={themeColors.bg}
borderBottom="1px solid"
borderColor={themeColors.dividerColor}
borderTopLeftRadius={borderRadius}
borderTopRightRadius={borderRadius}
pl={0}
pr={4}
py={2}
flexWrap="wrap"
gap={2}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={themeColors.unselectedText}
borderRadius="full"
px={4}
py={2}
fontSize="sm"
_selected={{
bg: themeColors.selectedBg,
color: themeColors.selectedText,
fontWeight: 'bold',
}}
_hover={{
bg: 'whiteAlpha.100',
}}
>
<HStack spacing={2}>
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,55 @@
/**
* TabContainer 常量和主题预设
*/
import type { ThemeColors, ThemePreset } from './types';
/**
* 主题预设配置
*/
export const THEME_PRESETS: Record<ThemePreset, Required<ThemeColors>> = {
// 黑金主题(原 Company 模块风格)
blackGold: {
bg: '#1A202C',
selectedBg: '#C9A961',
selectedText: '#FFFFFF',
unselectedText: '#D4AF37',
dividerColor: 'gray.600',
},
// 默认主题Chakra 风格)
default: {
bg: 'white',
selectedBg: 'blue.500',
selectedText: 'white',
unselectedText: 'gray.600',
dividerColor: 'gray.200',
},
// 深色主题
dark: {
bg: 'gray.800',
selectedBg: 'blue.400',
selectedText: 'white',
unselectedText: 'gray.300',
dividerColor: 'gray.600',
},
// 浅色主题
light: {
bg: 'gray.50',
selectedBg: 'blue.500',
selectedText: 'white',
unselectedText: 'gray.700',
dividerColor: 'gray.300',
},
};
/**
* 默认配置
*/
export const DEFAULT_CONFIG = {
themePreset: 'blackGold' as ThemePreset,
isLazy: true,
size: 'lg' as const,
borderRadius: 'lg',
shadow: 'lg',
panelPadding: 0,
};

View File

@@ -0,0 +1,134 @@
/**
* TabContainer 通用 Tab 容器组件
*
* 功能:
* - 管理 Tab 切换状态(支持受控/非受控模式)
* - 动态渲染 Tab 导航和内容
* - 支持多种主题预设(黑金、默认、深色、浅色)
* - 支持自定义主题颜色
* - 支持懒加载
*
* @example
* // 基础用法(传入 components
* <TabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1Content },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2Content },
* ]}
* componentProps={{ userId: '123' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
*
* @example
* // 自定义渲染用法(使用 children
* <TabContainer tabs={tabs} themePreset="dark">
* <TabPanel>自定义内容 1</TabPanel>
* <TabPanel>自定义内容 2</TabPanel>
* </TabContainer>
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Card,
CardBody,
Tabs,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import TabNavigation from './TabNavigation';
import { THEME_PRESETS, DEFAULT_CONFIG } from './constants';
import type { TabContainerProps, ThemeColors } from './types';
// 导出类型和常量
export type { TabConfig, ThemeColors, ThemePreset, TabContainerProps } from './types';
export { THEME_PRESETS } from './constants';
const TabContainer: React.FC<TabContainerProps> = ({
tabs,
componentProps = {},
onTabChange,
defaultIndex = 0,
index: controlledIndex,
themePreset = DEFAULT_CONFIG.themePreset,
themeColors: customThemeColors,
isLazy = DEFAULT_CONFIG.isLazy,
size = DEFAULT_CONFIG.size,
borderRadius = DEFAULT_CONFIG.borderRadius,
shadow = DEFAULT_CONFIG.shadow,
panelPadding = DEFAULT_CONFIG.panelPadding,
children,
}) => {
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
// 当前索引(支持受控/非受控)
const currentIndex = controlledIndex ?? internalIndex;
// 合并主题颜色(自定义颜色优先)
const themeColors: Required<ThemeColors> = useMemo(() => ({
...THEME_PRESETS[themePreset],
...customThemeColors,
}), [themePreset, customThemeColors]);
/**
* 处理 Tab 切换
*/
const handleTabChange = useCallback((newIndex: number) => {
const tabKey = tabs[newIndex]?.key || '';
// 触发回调
onTabChange?.(newIndex, tabKey, currentIndex);
// 非受控模式下更新内部状态
if (controlledIndex === undefined) {
setInternalIndex(newIndex);
}
}, [tabs, onTabChange, currentIndex, controlledIndex]);
/**
* 渲染 Tab 内容
*/
const renderTabPanels = () => {
// 如果传入了 children直接渲染 children
if (children) {
return children;
}
// 否则根据 tabs 配置渲染
return tabs.map((tab) => {
const Component = tab.component;
return (
<TabPanel key={tab.key} px={panelPadding} py={panelPadding}>
{Component ? <Component {...componentProps} /> : null}
</TabPanel>
);
});
};
return (
<Card shadow={shadow} bg={themeColors.bg} borderRadius={borderRadius}>
<CardBody p={0}>
<Tabs
isLazy={isLazy}
variant="unstyled"
size={size}
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航 */}
<TabNavigation
tabs={tabs}
themeColors={themeColors}
borderRadius={borderRadius}
/>
{/* Tab 内容面板 */}
<TabPanels>{renderTabPanels()}</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default TabContainer;

View File

@@ -0,0 +1,85 @@
/**
* TabContainer 通用 Tab 容器组件类型定义
*/
import type { ComponentType, ReactNode } from 'react';
import type { IconType } from 'react-icons';
/**
* Tab 配置项
*/
export interface TabConfig {
/** Tab 唯一标识 */
key: string;
/** Tab 显示名称 */
name: string;
/** Tab 图标(可选) */
icon?: IconType | ComponentType;
/** Tab 内容组件(可选,如果不传则使用 children 渲染) */
component?: ComponentType<any>;
}
/**
* 主题颜色配置
*/
export interface ThemeColors {
/** 容器背景色 */
bg?: string;
/** 选中 Tab 背景色 */
selectedBg?: string;
/** 选中 Tab 文字颜色 */
selectedText?: string;
/** 未选中 Tab 文字颜色 */
unselectedText?: string;
/** 分割线颜色 */
dividerColor?: string;
}
/**
* 预设主题类型
*/
export type ThemePreset = 'blackGold' | 'default' | 'dark' | 'light';
/**
* TabContainer 组件 Props
*/
export interface TabContainerProps {
/** Tab 配置数组 */
tabs: TabConfig[];
/** 传递给 Tab 内容组件的通用 props */
componentProps?: Record<string, any>;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string, prevIndex: number) => void;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** 主题预设 */
themePreset?: ThemePreset;
/** 自定义主题颜色(优先级高于预设) */
themeColors?: ThemeColors;
/** 是否启用懒加载 */
isLazy?: boolean;
/** Tab 尺寸 */
size?: 'sm' | 'md' | 'lg';
/** 容器圆角 */
borderRadius?: string;
/** 容器阴影 */
shadow?: string;
/** 自定义 Tab 面板内边距 */
panelPadding?: number | string;
/** 子元素(用于自定义渲染 Tab 内容) */
children?: ReactNode;
}
/**
* TabNavigation 组件 Props
*/
export interface TabNavigationProps {
/** Tab 配置数组 */
tabs: TabConfig[];
/** 主题颜色 */
themeColors: Required<ThemeColors>;
/** 容器圆角 */
borderRadius?: string;
}

View File

@@ -0,0 +1,107 @@
/**
* TabPanelContainer - Tab 面板通用容器组件
*
* 提供统一的:
* - Loading 状态处理
* - VStack 布局
* - 免责声明(可选)
*
* @example
* ```tsx
* <TabPanelContainer loading={loading} showDisclaimer>
* <YourContent />
* </TabPanelContainer>
* ```
*/
import React, { memo } from 'react';
import { VStack, Center, Spinner, Text, Box } from '@chakra-ui/react';
// 默认免责声明文案
const DEFAULT_DISCLAIMER =
'免责声明本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成未经许可严禁转载。所有内容仅供参考不构成任何投资建议请投资者注意风险独立审慎决策。';
export interface TabPanelContainerProps {
/** 是否处于加载状态 */
loading?: boolean;
/** 加载状态显示的文案 */
loadingMessage?: string;
/** 加载状态高度 */
loadingHeight?: string;
/** 自定义骨架屏组件,优先于默认 Spinner */
skeleton?: React.ReactNode;
/** 子组件间距,默认 6 */
spacing?: number;
/** 内边距,默认 4 */
padding?: number;
/** 是否显示免责声明,默认 false */
showDisclaimer?: boolean;
/** 自定义免责声明文案 */
disclaimerText?: string;
/** 子组件 */
children: React.ReactNode;
}
/**
* 加载状态组件
*/
const LoadingState: React.FC<{ message: string; height: string }> = ({
message,
height,
}) => (
<Center h={height}>
<VStack spacing={3}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
<Text fontSize="sm" color="gray.500">
{message}
</Text>
</VStack>
</Center>
);
/**
* 免责声明组件
*/
const DisclaimerText: React.FC<{ text: string }> = ({ text }) => (
<Text mt={4} color="gray.500" fontSize="12px" lineHeight="1.5">
{text}
</Text>
);
/**
* Tab 面板通用容器
*/
const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
({
loading = false,
loadingMessage = '加载中...',
loadingHeight = '200px',
skeleton,
spacing = 6,
padding = 4,
showDisclaimer = false,
disclaimerText = DEFAULT_DISCLAIMER,
children,
}) => {
if (loading) {
// 如果提供了自定义骨架屏,使用骨架屏;否则使用默认 Spinner
if (skeleton) {
return <>{skeleton}</>;
}
return <LoadingState message={loadingMessage} height={loadingHeight} />;
}
return (
<Box p={padding}>
<VStack spacing={spacing} align="stretch">
{children}
</VStack>
{showDisclaimer && <DisclaimerText text={disclaimerText} />}
</Box>
);
}
);
TabPanelContainer.displayName = 'TabPanelContainer';
export default TabPanelContainer;

View File

@@ -0,0 +1,216 @@
/**
* GlobalSidebarContext - 全局右侧工具栏状态管理
*
* 管理侧边栏的展开/收起状态和数据加载
* 自选股和关注事件数据都从 Redux 获取,与导航栏共用数据源
*/
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useAuth } from './AuthContext';
import { logger } from '@/utils/logger';
import {
loadWatchlist,
loadWatchlistQuotes,
toggleWatchlist,
loadFollowingEvents,
loadEventComments,
toggleFollowEvent
} from '@/store/slices/stockSlice';
const GlobalSidebarContext = createContext(null);
/**
* GlobalSidebarProvider - 全局侧边栏 Provider
*/
export const GlobalSidebarProvider = ({ children }) => {
const { user } = useAuth();
const userId = user?.id;
const dispatch = useDispatch();
// 侧边栏展开/收起状态(默认折叠)
const [isOpen, setIsOpen] = useState(false);
// 从 Redux 获取自选股数据(与导航栏共用)
const watchlist = useSelector(state => state.stock.watchlist || []);
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
const watchlistLoading = useSelector(state => state.stock.loading?.watchlist);
const quotesLoading = useSelector(state => state.stock.loading?.watchlistQuotes);
// 将 watchlistQuotes 数组转换为 { stock_code: quote } 格式(兼容现有组件)
const realtimeQuotes = React.useMemo(() => {
const quotesMap = {};
watchlistQuotes.forEach(item => {
quotesMap[item.stock_code] = item;
});
return quotesMap;
}, [watchlistQuotes]);
// 从 Redux 获取关注事件数据(与导航栏共用)
const followingEvents = useSelector(state => state.stock.followingEvents || []);
const eventComments = useSelector(state => state.stock.eventComments || []);
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
// 防止重复加载
const hasLoadedRef = useRef(false);
/**
* 切换侧边栏展开/收起
*/
const toggle = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
/**
* 加载实时行情(通过 Redux
*/
const loadRealtimeQuotes = useCallback(() => {
if (!userId) return;
dispatch(loadWatchlistQuotes());
}, [userId, dispatch]);
/**
* 加载所有数据(自选股和关注事件都从 Redux 获取)
*/
const loadData = useCallback(() => {
if (!userId) return;
// 自选股通过 Redux 加载
dispatch(loadWatchlist());
dispatch(loadWatchlistQuotes());
// 关注事件和评论通过 Redux 加载
dispatch(loadFollowingEvents());
dispatch(loadEventComments());
}, [userId, dispatch]);
/**
* 刷新数据
*/
const refresh = useCallback(async () => {
await loadData();
}, [loadData]);
/**
* 取消关注股票(通过 Redux
*/
const unwatchStock = useCallback(async (stockCode) => {
if (!userId) return;
try {
// 找到股票名称
const stockItem = watchlist.find(s => s.stock_code === stockCode);
const stockName = stockItem?.stock_name || '';
// 通过 Redux action 移除(乐观更新)
await dispatch(toggleWatchlist({
stockCode,
stockName,
isInWatchlist: true // 表示当前在自选股中,需要移除
})).unwrap();
logger.debug('GlobalSidebar', 'unwatchStock 成功', { stockCode });
} catch (error) {
logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId });
}
}, [userId, dispatch, watchlist]);
/**
* 取消关注事件(通过 Redux
*/
const unfollowEvent = useCallback(async (eventId) => {
if (!userId) return;
try {
// 通过 Redux action 取消关注(乐观更新)
await dispatch(toggleFollowEvent({
eventId,
isFollowing: true // 表示当前已关注,需要取消
})).unwrap();
logger.debug('GlobalSidebar', 'unfollowEvent 成功', { eventId });
} catch (error) {
logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId });
// 失败时重新加载列表
dispatch(loadFollowingEvents());
}
}, [userId, dispatch]);
// 用户登录后加载数据
useEffect(() => {
if (user && !hasLoadedRef.current) {
console.log('[GlobalSidebar] 用户登录,加载数据');
hasLoadedRef.current = true;
loadData();
}
// 用户登出时重置(所有状态由 Redux 管理)
if (!user) {
hasLoadedRef.current = false;
}
}, [user, loadData]);
// 页面可见性变化时刷新数据
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && user) {
console.log('[GlobalSidebar] 页面可见,刷新数据');
loadData();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [user, loadData]);
// 定时刷新实时行情(每分钟一次,两个面板共用)
useEffect(() => {
if (watchlist.length > 0 && userId) {
const interval = setInterval(() => {
console.log('[GlobalSidebar] 定时刷新行情');
dispatch(loadWatchlistQuotes());
}, 60000);
return () => clearInterval(interval);
}
}, [watchlist.length, userId, dispatch]);
const value = {
// 状态
isOpen,
toggle,
// 数据watchlist 和 realtimeQuotes 从 Redux 获取)
watchlist,
realtimeQuotes,
followingEvents,
eventComments,
// 加载状态
loading: watchlistLoading || eventsLoading,
quotesLoading,
// 方法
refresh,
loadRealtimeQuotes,
unwatchStock,
unfollowEvent,
};
return (
<GlobalSidebarContext.Provider value={value}>
{children}
</GlobalSidebarContext.Provider>
);
};
/**
* useGlobalSidebar - 获取全局侧边栏 Context
*/
export const useGlobalSidebar = () => {
const context = useContext(GlobalSidebarContext);
if (!context) {
throw new Error('useGlobalSidebar must be used within a GlobalSidebarProvider');
}
return context;
};
export default GlobalSidebarContext;

View File

@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
useEffect(() => {
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
return;
}
// ⚡ 防止 React Strict Mode 导致的重复初始化
if (socketInitialized) {
logger.debug('NotificationContext', 'Socket 已初始化跳过重复执行Strict Mode 保护)');

View File

@@ -0,0 +1,80 @@
/**
* 动态设置网页标题的 Hook
*/
import { useEffect } from 'react';
export interface UseDocumentTitleOptions {
/** 基础标题(默认:价值前沿) */
baseTitle?: string;
/** 是否在组件卸载时恢复基础标题 */
restoreOnUnmount?: boolean;
}
/**
* 动态设置网页标题
*
* @param title - 要显示的标题(会与 baseTitle 组合)
* @param options - 配置选项
*
* @example
* ```tsx
* // 基础用法
* useDocumentTitle('我的页面');
* // 结果: "我的页面 - 价值前沿"
*
* // 股票页面
* useDocumentTitle(stockName ? `${stockName}(${stockCode})` : stockCode);
* // 结果: "平安银行(000001) - 价值前沿"
*
* // 自定义基础标题
* useDocumentTitle('Dashboard', { baseTitle: 'My App' });
* // 结果: "Dashboard - My App"
* ```
*/
export const useDocumentTitle = (
title?: string | null,
options: UseDocumentTitleOptions = {}
): void => {
const { baseTitle = '价值前沿', restoreOnUnmount = true } = options;
useEffect(() => {
if (title) {
document.title = `${title} - ${baseTitle}`;
} else {
document.title = baseTitle;
}
// 组件卸载时恢复默认标题
if (restoreOnUnmount) {
return () => {
document.title = baseTitle;
};
}
}, [title, baseTitle, restoreOnUnmount]);
};
/**
* 股票页面专用的标题 Hook
*
* @param stockCode - 股票代码
* @param stockName - 股票名称(可选)
*
* @example
* ```tsx
* useStockDocumentTitle('000001', '平安银行');
* // 结果: "平安银行(000001) - 价值前沿"
* ```
*/
export const useStockDocumentTitle = (
stockCode: string,
stockName?: string | null
): void => {
const title = stockName
? `${stockName}(${stockCode})`
: stockCode || null;
useDocumentTitle(title);
};
export default useDocumentTitle;

View File

@@ -1,16 +1,21 @@
// src/hooks/useFollowingEvents.js
// 关注事件管理自定义 Hook
// 关注事件管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
import {
loadFollowingEvents as loadFollowingEventsAction,
toggleFollowEvent
} from '../store/slices/stockSlice';
const EVENTS_PAGE_SIZE = 8;
/**
* 关注事件管理 Hook
* 提供事件加载、分页、取消关注等功能
* 关注事件管理 Hook(导航栏专用)
* 提供关注事件加载、分页、取消关注等功能
* 监听 Redux 中的 followingEvents 变化,自动同步
*
* @returns {{
* followingEvents: Array,
@@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8;
*/
export const useFollowingEvents = () => {
const toast = useToast();
const [followingEvents, setFollowingEvents] = useState([]);
const [eventsLoading, setEventsLoading] = useState(false);
const dispatch = useDispatch();
const [eventsPage, setEventsPage] = useState(1);
// 加载关注事件
const loadFollowingEvents = useCallback(async () => {
try {
setEventsLoading(true);
const base = getApiBase();
const resp = await fetch(base + '/api/account/events/following', {
credentials: 'include',
cache: 'no-store'
});
if (resp.ok) {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
// 合并重复的事件(用最新的数据)
const eventMap = new Map();
for (const evt of data.data) {
if (evt && evt.id) {
eventMap.set(evt.id, evt);
}
}
const merged = Array.from(eventMap.values());
// 按创建时间降序排列(假设事件有 created_at 或 id
if (merged.length > 0 && merged[0].created_at) {
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
} else {
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
}
setFollowingEvents(merged);
} else {
setFollowingEvents([]);
}
} else {
setFollowingEvents([]);
}
} catch (e) {
logger.warn('useFollowingEvents', '加载关注事件失败', {
error: e.message
});
setFollowingEvents([]);
} finally {
setEventsLoading(false);
}
}, []);
// 从 Redux 获取关注事件数据(与 GlobalSidebar 共用)
const followingEvents = useSelector(state => state.stock.followingEvents || []);
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
// 取消关注事件
// 从 Redux 获取关注事件列表长度(用于监听变化)
const reduxEventsLength = useSelector(state => state.stock.followingEvents?.length || 0);
// 用于跟踪上一次的事件长度
const prevEventsLengthRef = useRef(-1);
// 初始化时加载 Redux followingEvents确保 Redux 状态被初始化)
const hasInitializedRef = useRef(false);
useEffect(() => {
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
logger.debug('useFollowingEvents', '初始化 Redux followingEvents');
dispatch(loadFollowingEventsAction());
}
}, [dispatch]);
// 加载关注事件(通过 Redux
const loadFollowingEvents = useCallback(() => {
logger.debug('useFollowingEvents', '触发 loadFollowingEvents');
dispatch(loadFollowingEventsAction());
}, [dispatch]);
// 监听 Redux followingEvents 长度变化,自动更新分页
useEffect(() => {
const currentLength = reduxEventsLength;
const prevLength = prevEventsLengthRef.current;
// 当事件列表长度变化时,更新分页(确保不超出范围)
if (prevLength !== -1 && currentLength !== prevLength) {
const newMaxPage = Math.max(1, Math.ceil(currentLength / EVENTS_PAGE_SIZE));
setEventsPage(p => Math.min(p, newMaxPage));
}
prevEventsLengthRef.current = currentLength;
}, [reduxEventsLength]);
// 取消关注事件(通过 Redux
const handleUnfollowEvent = useCallback(async (eventId) => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
credentials: 'include'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data && data.success !== false) {
setFollowingEvents((prev) => {
const updated = (prev || []).filter((x) => x.id !== eventId);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE));
setEventsPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
} else {
toast({ title: '操作失败', status: 'error', duration: 2000 });
}
// 通过 Redux action 取消关注(乐观更新)
await dispatch(toggleFollowEvent({
eventId,
isFollowing: true // 表示当前已关注,需要取消
})).unwrap();
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
} catch (e) {
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
logger.error('useFollowingEvents', '取消关注事件失败', e);
toast({ title: e.message || '操作失败', status: 'error', duration: 2000 });
// 失败时重新加载列表
dispatch(loadFollowingEventsAction());
}
}, [toast]);
}, [dispatch, toast]);
return {
followingEvents,

View File

@@ -3,6 +3,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { getApiBase } from '@utils/apiConfig';
/**
* 股票搜索 Hook
@@ -37,7 +38,7 @@ export const useStockSearch = (options = {}) => {
setIsSearching(true);
try {
const response = await fetch(
`/api/stocks/search?q=${encodeURIComponent(query.trim())}&limit=${limit}`
`${getApiBase()}/api/stocks/search?q=${encodeURIComponent(query.trim())}&limit=${limit}`
);
const data = await response.json();

View File

@@ -1,12 +1,16 @@
// src/hooks/useWatchlist.js
// 自选股管理自定义 Hook导航栏专用,与 Redux 状态同步)
// 自选股管理自定义 Hook与 Redux 状态同步,支持多组件共用
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
import {
toggleWatchlist as toggleWatchlistAction,
loadWatchlist,
loadWatchlistQuotes
} from '../store/slices/stockSlice';
const WATCHLIST_PAGE_SIZE = 10;
@@ -31,20 +35,18 @@ const WATCHLIST_PAGE_SIZE = 10;
export const useWatchlist = () => {
const toast = useToast();
const dispatch = useDispatch();
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
const [watchlistLoading, setWatchlistLoading] = useState(false);
const [watchlistPage, setWatchlistPage] = useState(1);
const [followingEvents, setFollowingEvents] = useState([]);
// 从 Redux 获取自选股数据(与 GlobalSidebar 共用)
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
const watchlistLoading = useSelector(state => state.stock.loading?.watchlistQuotes || false);
// 从 Redux 获取自选股列表长度(用于监听变化)
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
// 检查 Redux watchlist 是否已初始化(加载状态)
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
// 用于跟踪上一次的 watchlist 长度
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1确保第一次变化也能检测到
const prevWatchlistLengthRef = useRef(-1);
// 初始化时加载 Redux watchlist确保 Redux 状态被初始化)
const hasInitializedRef = useRef(false);
@@ -56,35 +58,11 @@ export const useWatchlist = () => {
}
}, [dispatch]);
// 加载自选股实时行情
const loadWatchlistQuotes = useCallback(async () => {
try {
setWatchlistLoading(true);
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist/realtime', {
credentials: 'include',
cache: 'no-store'
});
if (resp.ok) {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
setWatchlistQuotes(data.data);
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
} else {
setWatchlistQuotes([]);
}
} else {
setWatchlistQuotes([]);
}
} catch (e) {
logger.warn('useWatchlist', '加载自选股实时行情失败', {
error: e.message
});
setWatchlistQuotes([]);
} finally {
setWatchlistLoading(false);
}
}, []);
// 加载自选股实时行情(通过 Redux
const loadWatchlistQuotesFunc = useCallback(() => {
logger.debug('useWatchlist', '触发 loadWatchlistQuotes');
dispatch(loadWatchlistQuotes());
}, [dispatch]);
// 监听 Redux watchlist 长度变化,自动刷新行情数据
useEffect(() => {
@@ -102,7 +80,7 @@ export const useWatchlist = () => {
// 延迟一小段时间再刷新,确保后端数据已更新
const timer = setTimeout(() => {
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
loadWatchlistQuotes();
dispatch(loadWatchlistQuotes());
}, 500);
prevWatchlistLengthRef.current = currentLength;
@@ -111,66 +89,53 @@ export const useWatchlist = () => {
// 更新 ref
prevWatchlistLengthRef.current = currentLength;
}, [reduxWatchlistLength, loadWatchlistQuotes]);
}, [reduxWatchlistLength, dispatch]);
// 添加到自选股
// 添加到自选股(通过 Redux
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
// 刷新自选股列表
loadWatchlistQuotes();
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} else {
toast({ title: '添加失败', status: 'error', duration: 2000 });
return false;
}
// 通过 Redux action 添加(乐观更新)
await dispatch(toggleWatchlistAction({
stockCode,
stockName,
isInWatchlist: false // 表示当前不在自选股中,需要添加
})).unwrap();
// 刷新行情
dispatch(loadWatchlistQuotes());
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} catch (e) {
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
logger.error('useWatchlist', '添加自选股失败', e);
toast({ title: e.message || '添加失败', status: 'error', duration: 2000 });
return false;
}
}, [toast, loadWatchlistQuotes]);
}, [dispatch, toast]);
// 从自选股移除
// 从自选股移除(通过 Redux
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try {
// 找到股票名称
const stockItem = watchlistQuotes.find(item => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
return normalize6(item.stock_code) === normalize6(stockCode);
});
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const stockItem = watchlistQuotes.find(item =>
normalize6(item.stock_code) === normalize6(stockCode)
);
const stockName = stockItem?.stock_name || '';
// 通过 Redux action 移除(会同步更新 Redux 状态
// 通过 Redux action 移除(乐观更新
await dispatch(toggleWatchlistAction({
stockCode,
stockName,
isInWatchlist: true // 表示当前在自选股中,需要移除
})).unwrap();
// 更新本地状态(立即响应 UI
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
// 更新分页(如果当前页超出范围
const newLength = watchlistQuotes.length - 1;
const newMaxPage = Math.max(1, Math.ceil(newLength / WATCHLIST_PAGE_SIZE));
setWatchlistPage(p => Math.min(p, newMaxPage));
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} catch (e) {
@@ -195,7 +160,7 @@ export const useWatchlist = () => {
watchlistPage,
setWatchlistPage,
WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes,
loadWatchlistQuotes: loadWatchlistQuotesFunc,
followingEvents,
handleAddToWatchlist,
handleRemoveFromWatchlist,

View File

@@ -5,11 +5,25 @@ import { BrowserRouter as Router } from 'react-router-dom';
// ⚡ 性能监控:在应用启动时尽早标记
import { performanceMonitor } from './utils/performanceMonitor';
// T0: HTML 加载完成时间点
if (document.readyState === 'complete') {
performanceMonitor.mark('html-loaded');
} else {
window.addEventListener('load', () => {
performanceMonitor.mark('html-loaded');
});
}
// T1: React 开始初始化
performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css';
// 导入全局滚动条隐藏样式
import './styles/scrollbar-hide.css';
// 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css';

View File

@@ -2,13 +2,14 @@
// 主布局组件 - 为所有带导航栏的页面提供统一布局
import React, { memo, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { Box } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import HomeNavbar from "../components/Navbars/HomeNavbar";
import AppFooter from "./AppFooter";
import BackToTopButton from "./components/BackToTopButton";
import ErrorBoundary from "../components/ErrorBoundary";
import PageLoader from "../components/Loading/PageLoader";
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
import GlobalSidebar from "../components/GlobalSidebar";
import { BACK_TO_TOP_CONFIG, LAYOUT_SIZE } from "./config/layoutConfig";
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
@@ -27,6 +28,7 @@ const MemoizedAppFooter = memo(AppFooter);
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
* - ✅ 全局侧边栏 - 右侧可收起的工具栏(关注股票、事件动态)
*/
export default function MainLayout() {
return (
@@ -34,17 +36,26 @@ export default function MainLayout() {
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" pt="60px" bg="#1A202C">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</Box>
{/* 主体区域 - 页面内容 + 右侧全局侧边栏(绝对定位覆盖) */}
<Box flex="1" bg="#1A202C" position="relative" overflow="hidden">
{/* 页面内容区域 - 全宽度,与导航栏对齐 */}
<Box h="100%" overflowY="auto" display="flex" flexDirection="column">
<Box flex="1" pt={LAYOUT_SIZE.navbarHeight}>
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</Box>
{/* 页脚 - 在滚动区域内,随内容滚动 */}
<MemoizedAppFooter />
</Box>
{/* 页脚 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedAppFooter />
{/* 全局右侧工具栏 - 绝对定位覆盖在内容上方 */}
<Box position="absolute" top={0} right={0} bottom={0}>
<GlobalSidebar />
</Box>
</Box>
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
{/* <BackToTopButton

View File

@@ -12,13 +12,62 @@
/**
* Z-Index 层级管理
* 统一管理 z-index避免层级冲突
*
* 层级规则(从低到高):
* - 0-99: 页面内部元素(背景、卡片内部层级)
* - 100-499: 页面级浮动元素(侧边栏、面板)
* - 500-999: 全局固定元素(工具栏、返回顶部)
* - 1000-1499: 导航相关(导航栏、状态栏)
* - 1500-1999: 弹出层下拉菜单、Popover
* - 2000-2999: 模态框(普通 Modal
* - 3000-8999: 特殊模态框(图表全屏、预览)
* - 9000-9999: 全局提示Toast、通知
* - 10000+: 系统级覆盖(第三方组件、客服系统)
*/
export const Z_INDEX = {
BACK_TO_TOP: 1000, // 返回顶部按钮
NAVBAR: 1100, // 导航栏
MODAL: 1200, // 模态框
TOAST: 1300, // 提示消息
TOOLTIP: 1400, // 工具提示
// === 页面内部元素 (0-99) ===
BACKGROUND: 0, // 背景层
CARD_CONTENT: 1, // 卡片内容
CARD_OVERLAY: 2, // 卡片覆盖层
// === 页面级浮动元素 (100-499) ===
SIDEBAR: 100, // 全局侧边栏
STICKY_HEADER: 200, // 粘性表头
// === 全局固定元素 (500-999) ===
BACK_TO_TOP: 900, // 返回顶部按钮
AUTH_MODAL_BG: 999, // 认证模态框背景
// === 导航相关 (1000-1499) ===
NAVBAR: 1000, // 顶部导航栏
CONNECTION_STATUS: 1050, // 连接状态栏
PROFILE_ALERT: 1100, // 个人资料提示
// === 弹出层 (1500-1999) ===
DROPDOWN: 1500, // 下拉菜单
POPOVER: 1600, // Popover 弹出
TOOLTIP: 1700, // 工具提示
CITATION: 1800, // 引用标记
// === 模态框 (2000-2999) ===
MODAL: 2000, // 普通模态框
MODAL_OVERLAY: 2001, // 模态框遮罩
STOCK_CHART_MODAL: 2500, // 股票图表模态框
// === 特殊模态框 (3000-8999) ===
FULLSCREEN: 3000, // 全屏模式
IMAGE_PREVIEW: 5000, // 图片预览
// === 全局提示 (9000-9999) ===
NOTIFICATION: 9000, // 通知容器
TOAST: 9500, // Toast 提示
SEARCH_DROPDOWN: 9800, // 搜索下拉框
PERFORMANCE_PANEL: 9900, // 性能面板(开发用)
// === 系统级覆盖 (10000+) ===
KLINE_FULLSCREEN: 10000, // K线图全屏
THIRD_PARTY: 99999, // 第三方组件
BYTEDESK: 999999, // Bytedesk 客服系统
};
/**
@@ -116,9 +165,9 @@ export const PAGE_LOADER_CONFIG = {
* 布局尺寸配置
*/
export const LAYOUT_SIZE = {
navbarHeight: '80px',
navbarHeight: '60px', // 导航栏统一高度
footerHeight: 'auto',
contentMinHeight: 'calc(100vh - 80px)', // 100vh - navbar高度
contentMinHeight: 'calc(100vh - 60px)', // 100vh - navbar高度
};
/**

124
src/lib/echarts.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* ECharts 按需导入配置
*
* 使用方式:
* import { echarts } from '@lib/echarts';
*
* 优势:
* - 减小打包体积(从 ~800KB 降至 ~200-300KB
* - Tree-shaking 支持
* - 统一管理图表类型和组件
*/
// 核心模块
import * as echarts from 'echarts/core';
// 图表类型 - 按需导入
import {
LineChart,
BarChart,
PieChart,
CandlestickChart,
ScatterChart,
} from 'echarts/charts';
// 组件 - 按需导入
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
MarkAreaComponent,
DatasetComponent,
TransformComponent,
} from 'echarts/components';
// 渲染器
import { CanvasRenderer } from 'echarts/renderers';
// 类型导出
import type {
ECharts,
EChartsCoreOption,
SetOptionOpts,
ComposeOption,
} from 'echarts/core';
import type {
LineSeriesOption,
BarSeriesOption,
PieSeriesOption,
CandlestickSeriesOption,
ScatterSeriesOption,
} from 'echarts/charts';
import type {
TitleComponentOption,
TooltipComponentOption,
LegendComponentOption,
GridComponentOption,
DataZoomComponentOption,
ToolboxComponentOption,
MarkLineComponentOption,
MarkPointComponentOption,
MarkAreaComponentOption,
DatasetComponentOption,
} from 'echarts/components';
// 注册必需的组件
echarts.use([
// 图表类型
LineChart,
BarChart,
PieChart,
CandlestickChart,
ScatterChart,
// 组件
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
MarkAreaComponent,
DatasetComponent,
TransformComponent,
// 渲染器
CanvasRenderer,
]);
// 组合类型定义(用于 TypeScript 类型推断)
export type ECOption = ComposeOption<
| LineSeriesOption
| BarSeriesOption
| PieSeriesOption
| CandlestickSeriesOption
| ScatterSeriesOption
| TitleComponentOption
| TooltipComponentOption
| LegendComponentOption
| GridComponentOption
| DataZoomComponentOption
| ToolboxComponentOption
| MarkLineComponentOption
| MarkPointComponentOption
| MarkAreaComponentOption
| DatasetComponentOption
>;
// 导出
export { echarts };
// EChartsOption 类型别名(兼容旧代码)
export type EChartsOption = EChartsCoreOption;
export type { ECharts, SetOptionOpts };
// 默认导出(兼容 import * as echarts from 'echarts' 的用法)
export default echarts;

View File

@@ -770,6 +770,144 @@ export const mockInvestmentPlans = [
updated_at: '2024-10-08T10:00:00Z',
tags: ['季度复盘', '半导体', 'Q3'],
stocks: ['688981.SH', '002371.SZ']
},
// ==================== 今日数据(用于日历视图展示) ====================
// 测试同日期多事件显示:计划 x3, 复盘 x3, 系统 x3
{
id: 320,
user_id: 1,
type: 'plan',
title: '今日交易计划 - 年末布局',
content: `【今日目标】
重点关注年末资金流向,寻找低位优质标的布局机会。
【操作计划】
1. 白酒板块:观察茅台、五粮液走势,若出现回调可适当加仓
2. 新能源宁德时代逢低补仓目标价位160元附近
3. AI算力关注寒武纪的突破信号
【资金安排】
- 当前仓位65%
- 可动用资金35%
- 计划使用资金15%分3笔建仓
【风险控制】
- 单笔止损:-3%
- 日内最大亏损:-5%
- 不追涨,只接回调`,
target_date: '2025-12-23',
status: 'active',
created_at: '2025-12-23T08:30:00Z',
updated_at: '2025-12-23T08:30:00Z',
tags: ['日计划', '年末布局'],
stocks: ['600519.SH', '300750.SZ', '688256.SH']
},
{
id: 321,
user_id: 1,
type: 'review',
title: '今日交易复盘 - 市场震荡',
content: `【操作回顾】
1. 上午10:30 在茅台1580元位置加仓0.5%
2. 下午14:00 宁德时代触及160元支撑位建仓1%
3. AI算力板块异动寒武纪涨幅超5%,观望未操作
【盈亏分析】
- 茅台加仓部分:浮盈+0.8%
- 宁德时代:浮亏-0.3%(正常波动范围内)
- 当日账户变动:+0.15%
【经验总结】
- 茅台买点把握较好,符合预期的回调位置
- 宁德时代略显急躁,可以再等一等
- AI算力虽然错过涨幅但不追高的纪律执行到位
【明日计划】
- 继续持有今日新增仓位
- 如茅台继续上涨至1620可考虑获利了结一半
- 关注周五PMI数据公布对市场影响`,
target_date: '2025-12-23',
status: 'completed',
created_at: '2025-12-23T15:30:00Z',
updated_at: '2025-12-23T16:00:00Z',
tags: ['日复盘', '年末交易'],
stocks: ['600519.SH', '300750.SZ']
},
// 额外计划2测试同日期多计划显示
{
id: 322,
user_id: 1,
type: 'plan',
title: 'AI算力板块布局',
content: `【目标】捕捉AI算力板块机会
【策略】
- 寒武纪:关注突破信号
- 中科曙光:服务器龙头
- 浪潮信息:算力基础设施`,
target_date: '2025-12-23',
status: 'pending',
created_at: '2025-12-23T09:00:00Z',
updated_at: '2025-12-23T09:00:00Z',
tags: ['AI', '算力'],
stocks: ['688256.SH', '603019.SH', '000977.SZ']
},
// 额外计划3测试同日期多计划显示
{
id: 323,
user_id: 1,
type: 'plan',
title: '医药板块观察计划',
content: `【目标】关注创新药投资机会
【策略】
- 恒瑞医药:创新药龙头
- 药明康德CRO龙头`,
target_date: '2025-12-23',
status: 'pending',
created_at: '2025-12-23T10:00:00Z',
updated_at: '2025-12-23T10:00:00Z',
tags: ['医药', '创新药'],
stocks: ['600276.SH', '603259.SH']
},
// 额外复盘2测试同日期多复盘显示
{
id: 324,
user_id: 1,
type: 'review',
title: '半导体操作复盘',
content: `【操作回顾】
- 中芯国际:持仓未动
- 北方华创:观望
【经验总结】
半导体板块整体震荡,等待突破信号`,
target_date: '2025-12-23',
status: 'completed',
created_at: '2025-12-23T16:00:00Z',
updated_at: '2025-12-23T16:30:00Z',
tags: ['半导体复盘'],
stocks: ['688981.SH', '002371.SZ']
},
// 额外复盘3测试同日期多复盘显示
{
id: 325,
user_id: 1,
type: 'review',
title: '本周白酒持仓复盘',
content: `【操作回顾】
- 茅台本周加仓0.5%
- 五粮液:持仓未动
【盈亏分析】
白酒板块本周表现平稳,继续持有`,
target_date: '2025-12-23',
status: 'completed',
created_at: '2025-12-23T17:00:00Z',
updated_at: '2025-12-23T17:30:00Z',
tags: ['白酒复盘', '周复盘'],
stocks: ['600519.SH', '000858.SZ']
}
];
@@ -1101,6 +1239,87 @@ export const mockCalendarEvents = [
is_recurring: true,
recurrence_rule: 'weekly',
created_at: '2025-01-01T10:00:00Z'
},
// ==================== 今日事件2025-12-23 ====================
{
id: 409,
user_id: 1,
title: '比亚迪全球发布会',
date: '2025-12-23',
event_date: '2025-12-23',
type: 'earnings',
category: 'company_event',
description: `比亚迪将于今日14:00召开全球发布会预计发布新一代刀片电池技术和2026年新车规划。
重点关注:
1. 刀片电池2.0技术参数:能量密度提升预期
2. 2026年新车型规划高端品牌仰望系列
3. 海外市场扩张计划:欧洲建厂进度
4. 年度交付量预告
投资建议:
- 关注发布会后股价走势
- 若技术突破超预期,可考虑加仓
- 设置止损位:当前价-5%`,
stock_code: '002594.SZ',
stock_name: '比亚迪',
importance: 5,
source: 'future',
stocks: ['002594.SZ', '300750.SZ', '601238.SH'],
created_at: '2025-12-20T10:00:00Z'
},
{
id: 410,
user_id: 1,
title: '12月LPR报价公布',
date: '2025-12-23',
event_date: '2025-12-23',
type: 'policy',
category: 'macro_policy',
description: `中国人民银行将于今日9:30公布12月贷款市场报价利率LPR
市场预期:
- 1年期LPR3.10%(维持不变)
- 5年期以上LPR3.60%(维持不变)
影响板块:
1. 银行板块:利差压力关注
2. 房地产:按揭成本影响
3. 基建:融资成本变化
投资策略:
- 若降息,利好成长股,可加仓科技板块
- 若维持,银行股防守价值凸显`,
importance: 5,
source: 'future',
stocks: ['601398.SH', '600036.SH', '000001.SZ'],
created_at: '2025-12-20T08:00:00Z'
},
{
id: 411,
user_id: 1,
title: 'A股年末交易策略会议',
date: '2025-12-23',
event_date: '2025-12-23',
type: 'reminder',
category: 'personal',
description: `个人备忘:年末交易策略规划
待办事项:
1. 回顾2025年度投资收益
2. 分析持仓股票基本面变化
3. 制定2026年Q1布局计划
4. 检查止盈止损纪律执行情况
重点关注:
- 白酒板块持仓是否需要调整
- 新能源板块估值是否合理
- 是否需要增加防守性配置`,
importance: 3,
source: 'user',
stocks: [],
created_at: '2025-12-22T20:00:00Z'
}
];

File diff suppressed because it is too large Load Diff

View File

@@ -609,6 +609,49 @@ function generateEventDescription(industry, importance, seed) {
return impacts[importance] + details[seed % details.length];
}
// 概念到层级结构的映射(模拟真实 API 的 concept_hierarchy
const conceptHierarchyMap = {
// 人工智能主线
'大模型': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI大模型', lv3_id: 'AI_LLM' },
'AI应用': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI应用场景', lv3_id: 'AI_APP' },
'算力': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' },
'数据': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: '数据要素', lv3_id: 'DATA' },
'机器学习': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI算法', lv3_id: 'AI_ALGO' },
'AI芯片': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' },
// 半导体主线
'芯片': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片设计', lv2_id: 'CHIP_DESIGN', lv3: '芯片设计', lv3_id: 'CHIP' },
'晶圆': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片制造', lv2_id: 'CHIP_MFG', lv3: '晶圆代工', lv3_id: 'WAFER' },
'封测': { lv1: '半导体', lv1_id: 'SEMI', lv2: '封装测试', lv2_id: 'PKG_TEST', lv3: '封装测试', lv3_id: 'PKG' },
'国产替代': { lv1: '半导体', lv1_id: 'SEMI', lv2: '国产替代', lv2_id: 'DOMESTIC', lv3: '自主可控', lv3_id: 'SELF_CTRL' },
// 新能源主线
'电池': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '动力电池', lv3_id: 'BATTERY' },
'光伏': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '光伏产业', lv2_id: 'SOLAR', lv3: '光伏组件', lv3_id: 'PV_MODULE' },
'储能': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '储能产业', lv2_id: 'ESS', lv3: '电化学储能', lv3_id: 'ESS_CHEM' },
'新能源车': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '整车制造', lv3_id: 'EV_OEM' },
'锂电': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '锂电池材料', lv3_id: 'LI_MATERIAL' },
// 医药主线
'创新药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '创新药', lv2_id: 'INNOV_DRUG', lv3: '创新药研发', lv3_id: 'DRUG_RD' },
'CRO': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医药服务', lv2_id: 'PHARMA_SVC', lv3: 'CRO/CDMO', lv3_id: 'CRO' },
'医疗器械': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医疗器械', lv2_id: 'MED_DEVICE', lv3: '高端器械', lv3_id: 'HI_DEVICE' },
'生物制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '生物制药', lv2_id: 'BIO_PHARMA', lv3: '生物药', lv3_id: 'BIO_DRUG' },
'仿制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '仿制药', lv2_id: 'GENERIC', lv3: '仿制药', lv3_id: 'GEN_DRUG' },
// 消费主线
'白酒': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '白酒', lv3_id: 'BAIJIU' },
'食品': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '食品加工', lv3_id: 'FOOD' },
'家电': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '家电', lv2_id: 'HOME_APPL', lv3: '白色家电', lv3_id: 'WHITE_APPL' },
'零售': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '零售连锁', lv3_id: 'CHAIN' },
'免税': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '免税', lv3_id: 'DUTY_FREE' },
// 通用概念(分配到多个主线)
'政策': { lv1: '宏观政策', lv1_id: 'MACRO', lv2: '产业政策', lv2_id: 'POLICY', lv3: null, lv3_id: null },
'利好': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '利好因素', lv2_id: 'POSITIVE', lv3: null, lv3_id: null },
'业绩': { lv1: '基本面', lv1_id: 'FUNDAMENTAL', lv2: '业绩增长', lv2_id: 'EARNINGS', lv3: null, lv3_id: null },
'涨停': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '涨停板', lv2_id: 'LIMIT_UP', lv3: null, lv3_id: null },
'龙头': { lv1: '投资策略', lv1_id: 'STRATEGY', lv2: '龙头股', lv2_id: 'LEADER', lv3: null, lv3_id: null },
'突破': { lv1: '技术面', lv1_id: 'TECHNICAL', lv2: '技术突破', lv2_id: 'BREAKOUT', lv3: null, lv3_id: null },
'合作': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '战略合作', lv2_id: 'PARTNERSHIP', lv3: null, lv3_id: null },
'投资': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '投资并购', lv2_id: 'MA', lv3: null, lv3_id: null },
};
// 生成关键词(对象数组格式,包含完整信息)
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
@@ -701,6 +744,16 @@ function generateKeywords(industry, seed) {
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% 的涨跌幅
// 获取概念的层级信息
const hierarchy = conceptHierarchyMap[name] || {
lv1: industry || '其他',
lv1_id: 'OTHER',
lv2: '未分类',
lv2_id: 'UNCATEGORIZED',
lv3: null,
lv3_id: null
};
return {
concept: name, // 使用 concept 字段而不是 name
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
@@ -711,7 +764,8 @@ function generateKeywords(industry, seed) {
},
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
stocks: generateRelatedStocks(name, seed + index), // 核心相关股票
hierarchy: hierarchy // 层级信息(用于按主线分组)
};
});
}
@@ -874,8 +928,20 @@ export function generateMockEvents(params = {}) {
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
// 搜索 related_stocks 中的股票名称和代码
(e.related_stocks && e.related_stocks.some(stock =>
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
)) ||
// 搜索行业
(e.industry && e.industry.toLowerCase().includes(query))
);
// 如果搜索结果为空,返回所有事件(宽松模式)
if (filteredEvents.length === 0) {
filteredEvents = allEvents;
}
}
// 行业筛选
@@ -1042,7 +1108,7 @@ function generateTransmissionChain(industry, index) {
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
nodeName = industryStock.stock_name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
@@ -1076,6 +1142,138 @@ function generateTransmissionChain(industry, index) {
return { nodes, edges };
}
// 主线事件标题模板 - 确保生成的事件能够匹配主线定义的关键词
// 每个主线定义至少有 2-3 个事件模板,确保数据充足
const mainlineEventTemplates = [
// ==================== TMT (科技/媒体/通信) ====================
// AI基础设施 - 算力/芯片 (L3_AI_CHIP)
{ title: '英伟达发布新一代GPUAI算力大幅提升', keywords: ['算力', 'AI芯片', 'GPU', '英伟达'], industry: '人工智能' },
{ title: '华为昇腾芯片出货量创新高国产AI算力加速', keywords: ['华为昇腾', 'AI芯片', '算力'], industry: '人工智能' },
{ title: '寒武纪发布新一代AI训练芯片性能提升50%', keywords: ['寒武纪', 'AI芯片', '算力'], industry: '人工智能' },
{ title: 'AI芯片需求激增GPU供不应求价格上涨', keywords: ['AI芯片', 'GPU', '算力'], industry: '人工智能' },
// AI基础设施 - 服务器与数据中心 (L3_AI_SERVER)
{ title: '智算中心建设加速,多地启动算力基础设施项目', keywords: ['智算中心', '算力', '数据中心'], industry: '人工智能' },
{ title: '液冷技术成数据中心标配,服务器散热升级', keywords: ['液冷', '数据中心', '服务器'], industry: '人工智能' },
{ title: 'AI服务器订单暴增数据中心扩容需求旺盛', keywords: ['服务器', '数据中心', '智算中心'], industry: '人工智能' },
// AI基础设施 - 光通信 (L3_OPTICAL)
{ title: 'CPO技术迎来突破光模块成本大幅下降', keywords: ['CPO', '光模块', '光通信'], industry: '通信' },
{ title: '800G光模块量产加速AI训练网络升级', keywords: ['光模块', '光通信', '光芯片'], industry: '通信' },
{ title: '光芯片技术突破CPO方案渗透率提升', keywords: ['光芯片', 'CPO', '光通信', '光模块'], industry: '通信' },
// AI基础设施 - PCB与封装 (L3_PCB)
{ title: 'AI PCB需求激增高多层板产能紧张', keywords: ['PCB', 'AI PCB', '封装'], industry: '电子' },
{ title: '先进封装技术突破PCB产业链升级', keywords: ['封装', 'PCB'], industry: '电子' },
// AI应用与大模型 (L3_AI_APP)
{ title: 'DeepSeek发布最新大模型推理能力超越GPT-4', keywords: ['DeepSeek', '大模型', 'AI', '人工智能'], industry: '人工智能' },
{ title: 'KIMI月活突破1亿国产大模型竞争白热化', keywords: ['KIMI', '大模型', 'AI', '人工智能'], industry: '人工智能' },
{ title: 'ChatGPT新版本发布AI Agent智能体能力升级', keywords: ['ChatGPT', '大模型', '智能体', 'AI'], industry: '人工智能' },
{ title: '人工智能+医疗深度融合AI辅助诊断准确率超90%', keywords: ['人工智能', 'AI', '医疗'], industry: '人工智能' },
{ title: '多模态大模型技术突破AI应用场景扩展', keywords: ['大模型', 'AI', '人工智能'], industry: '人工智能' },
// 半导体 - 芯片设计 (L3_CHIP_DESIGN)
{ title: '芯片设计企业扩产IC设计产能大幅提升', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
{ title: '国产芯片设计工具取得突破EDA软件自主可控', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
// 半导体 - 芯片制造 (L3_CHIP_MFG)
{ title: '中芯国际N+1工艺量产芯片制造技术再突破', keywords: ['中芯国际', '芯片制造', '晶圆'], industry: '半导体' },
{ title: '国产光刻机实现技术突破,半导体设备自主可控', keywords: ['光刻', '半导体', '芯片制造'], industry: '半导体' },
{ title: '晶圆产能利用率回升,半导体行业景气度上行', keywords: ['晶圆', '半导体', '芯片制造'], industry: '半导体' },
// 机器人 - 人形机器人 (L3_HUMANOID)
{ title: '特斯拉人形机器人Optimus量产提速成本降至2万美元', keywords: ['人形机器人', '特斯拉机器人', '具身智能'], industry: '人工智能' },
{ title: '具身智能迎来突破,人形机器人商业化加速', keywords: ['具身智能', '人形机器人', '机器人'], industry: '人工智能' },
{ title: '人形机器人产业链爆发,核心零部件需求激增', keywords: ['人形机器人', '具身智能'], industry: '人工智能' },
// 机器人 - 工业机器人 (L3_INDUSTRIAL_ROBOT)
{ title: '工业机器人出货量同比增长30%,自动化渗透率提升', keywords: ['工业机器人', '自动化', '机器人'], industry: '机械' },
{ title: '智能制造升级,机器人自动化需求持续增长', keywords: ['机器人', '自动化', '工业机器人'], industry: '机械' },
// 消费电子 - 智能手机 (L3_MOBILE)
{ title: '华为Mate系列销量火爆折叠屏手机市场爆发', keywords: ['华为', '手机', '折叠屏'], industry: '消费电子' },
{ title: '小米可穿戴设备出货量全球第一,智能手表市场扩张', keywords: ['小米', '可穿戴', '手机'], industry: '消费电子' },
{ title: '手机市场复苏5G手机换机潮来临', keywords: ['手机', '华为', '小米'], industry: '消费电子' },
// 消费电子 - XR与可穿戴 (L3_XR)
{ title: '苹果Vision Pro销量不及预期XR设备面临挑战', keywords: ['苹果', 'Vision Pro', 'XR', 'MR'], industry: '消费电子' },
{ title: 'AR眼镜成新风口VR/AR设备渗透率提升', keywords: ['AR', 'VR', 'XR'], industry: '消费电子' },
{ title: 'Meta发布新一代VR头显XR市场竞争加剧', keywords: ['VR', 'XR', 'MR', '可穿戴'], industry: '消费电子' },
// 通信 - 5G/6G通信 (L3_5G)
{ title: '5G基站建设加速运营商资本开支超预期', keywords: ['5G', '基站', '通信'], industry: '通信' },
{ title: '6G技术标准制定启动下一代通信网络布局', keywords: ['6G', '5G', '通信'], industry: '通信' },
// 通信 - 云计算与软件 (L3_CLOUD)
{ title: '云计算市场规模突破万亿SaaS企业业绩增长', keywords: ['云计算', 'SaaS', '软件', '互联网'], industry: '互联网' },
{ title: '数字化转型加速,企业级软件需求旺盛', keywords: ['数字化', '软件', '互联网'], industry: '互联网' },
{ title: '国产软件替代加速,信创产业迎来发展机遇', keywords: ['软件', '数字化', '互联网'], industry: '互联网' },
// ==================== 新能源与智能汽车 ====================
// 新能源 - 光伏 (L3_PV)
{ title: '光伏装机量创新高,太阳能发电成本持续下降', keywords: ['光伏', '太阳能', '硅片', '组件'], industry: '新能源' },
{ title: '光伏组件价格企稳,行业出清接近尾声', keywords: ['光伏', '组件', '硅片'], industry: '新能源' },
{ title: '分布式光伏爆发,太阳能产业链受益', keywords: ['光伏', '太阳能'], industry: '新能源' },
// 新能源 - 储能与电池 (L3_STORAGE)
{ title: '储能市场爆发式增长,电池需求大幅提升', keywords: ['储能', '电池', '锂电', '新能源'], industry: '新能源' },
{ title: '固态电池技术突破,新能源汽车续航大幅提升', keywords: ['固态电池', '电池', '锂电', '新能源'], industry: '新能源' },
{ title: '钠电池产业化加速,储能成本有望大幅下降', keywords: ['电池', '储能', '新能源'], industry: '新能源' },
// 智能汽车 - 新能源整车 (L3_EV_OEM)
{ title: '比亚迪月销量突破50万辆新能源汽车市占率第一', keywords: ['比亚迪', '新能源汽车', '电动车'], industry: '新能源' },
{ title: '新能源整车出口创新高,中国汽车品牌走向全球', keywords: ['新能源汽车', '整车', '电动车'], industry: '新能源' },
{ title: '电动车价格战持续新能源汽车渗透率突破50%', keywords: ['电动车', '新能源汽车', '整车'], industry: '新能源' },
// 智能汽车 - 智能驾驶 (L3_AUTO_DRIVE)
{ title: '特斯拉FSD自动驾驶进入中国市场智能驾驶加速', keywords: ['特斯拉', '自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
{ title: '车路协同试点扩大,智能网联汽车基建提速', keywords: ['车路协同', '智能网联', '智能驾驶'], industry: '新能源' },
{ title: 'L3级自动驾驶获批智能驾驶产业化加速', keywords: ['自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
// ==================== 先进制造 ====================
// 低空经济 - 无人机 (L3_DRONE)
{ title: '低空经济政策密集出台,无人机产业迎来风口', keywords: ['低空', '无人机', '空域'], industry: '航空' },
{ title: '无人机应用场景拓展,低空经济市场规模扩大', keywords: ['无人机', '低空', '空域'], industry: '航空' },
// 低空经济 - eVTOL (L3_EVTOL)
{ title: 'eVTOL飞行汽车完成首飞空中出租车商业化在即', keywords: ['eVTOL', '飞行汽车', '空中出租车'], industry: '航空' },
{ title: '飞行汽车获适航认证eVTOL商业运营启动', keywords: ['飞行汽车', 'eVTOL', '空中出租车'], industry: '航空' },
// 军工 - 航空航天 (L3_AEROSPACE)
{ title: '商业航天发射成功,卫星互联网建设加速', keywords: ['航天', '卫星', '火箭'], industry: '军工' },
{ title: '航空发动机国产化取得突破,航空产业链升级', keywords: ['航空', '军工'], industry: '军工' },
{ title: '卫星通信需求爆发,航天发射频次创新高', keywords: ['卫星', '航天', '火箭'], industry: '军工' },
// 军工 - 国防军工 (L3_DEFENSE)
{ title: '军工订单饱满,国防装备现代化提速', keywords: ['军工', '国防', '军工装备'], industry: '军工' },
{ title: '国防预算增长,军工装备需求持续提升', keywords: ['国防', '军工', '军工装备', '导弹'], industry: '军工' },
// ==================== 医药健康 ====================
// 医药 - 创新药 (L3_DRUG)
{ title: '创新药获批上市,医药板块迎来业绩兑现', keywords: ['创新药', '医药', '生物'], industry: '医药' },
{ title: 'CXO订单持续增长医药研发外包景气度高', keywords: ['CXO', '医药', '生物'], industry: '医药' },
{ title: '生物医药融资回暖,创新药研发管线丰富', keywords: ['医药', '生物', '创新药'], industry: '医药' },
// 医药 - 医疗器械 (L3_DEVICE)
{ title: '医疗器械集采扩面,国产替代加速', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
{ title: '高端医疗设备国产化突破,医疗器械出口增长', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
// ==================== 金融 ====================
// 金融 - 银行 (L3_BANK)
{ title: '银行净息差企稳,金融板块估值修复', keywords: ['银行', '金融'], industry: '金融' },
{ title: '银行业绩超预期,金融股迎来价值重估', keywords: ['银行', '金融'], industry: '金融' },
// 金融 - 券商 (L3_BROKER)
{ title: '券商业绩大幅增长,证券板块活跃', keywords: ['券商', '证券', '金融'], industry: '金融' },
{ title: 'A股成交量放大证券行业业绩弹性显现', keywords: ['证券', '券商', '金融'], industry: '金融' },
];
/**
* 生成动态新闻事件(实时要闻·动态追踪专用)
* @param {Object} timeRange - 时间范围 { startTime, endTime }
@@ -1100,7 +1298,10 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const timeSpan = endTime.getTime() - startTime.getTime();
for (let i = 0; i < count; i++) {
const industry = industries[i % industries.length];
// 使用主线事件模板生成事件,确保能匹配主线关键词
const templateIndex = i % mainlineEventTemplates.length;
const template = mainlineEventTemplates[templateIndex];
const industry = template.industry;
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
@@ -1133,7 +1334,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
stock_name: stock.stock_name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
@@ -1145,17 +1346,39 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
stock_name: randomStock.stock_name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}
}
// 使用模板标题,并生成包含模板关键词的 keywords 数组
const eventTitle = template.title;
const eventDescription = generateEventDescription(industry, imp, i);
// 生成关键词对象数组,包含模板中的关键词
const templateKeywords = template.keywords.map((kw, idx) => ({
concept: kw,
stock_count: 10 + Math.floor(Math.random() * 20),
score: parseFloat((0.7 + Math.random() * 0.25).toFixed(2)),
description: `${kw}相关概念,市场关注度较高`,
price_info: {
avg_change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2))
},
match_type: ['hybrid_knn', 'keyword', 'semantic'][idx % 3]
}));
// 合并模板关键词和行业关键词
const mergedKeywords = [
...templateKeywords,
...generateKeywords(industry, i).slice(0, 2)
];
events.push({
id: `dynamic_${i + 1}`,
title: generateEventTitle(industry, i),
description: generateEventDescription(industry, imp, i),
content: generateEventDescription(industry, imp, i),
title: eventTitle,
description: eventDescription,
content: eventDescription,
event_type: eventType,
importance: imp,
status: 'published',
@@ -1168,7 +1391,9 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
related_week_chg: parseFloat(relatedWeekChg),
keywords: generateKeywords(industry, i),
expectation_surprise_score: Math.floor(Math.random() * 60) + 30, // 30-90 超预期得分
keywords: mergedKeywords,
related_concepts: mergedKeywords, // 添加 related_concepts 字段,兼容主线匹配逻辑
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
industry: industry,
related_stocks: relatedStocks,

View File

@@ -3,80 +3,344 @@
// 生成财务数据
export const generateFinancialData = (stockCode) => {
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
// 12 期数据 - 用于财务指标表格7个指标Tab
const metricsPeriods = [
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
'2022-09-30', '2022-06-30', '2022-03-31', '2021-12-31',
];
// 8 期数据 - 用于财务报表3个报表Tab
const statementPeriods = [
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
];
// 兼容旧代码
const periods = statementPeriods.slice(0, 4);
return {
stockCode,
// 股票基本信息
stockInfo: {
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例公司',
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
industry: stockCode === '000001' ? '银行' : '制造业',
list_date: '1991-04-03',
market: 'SZ'
market: 'SZ',
// 关键指标
key_metrics: {
eps: 2.72,
roe: 16.23,
gross_margin: 71.92,
net_margin: 32.56,
roa: 1.05
},
// 增长率
growth_rates: {
revenue_growth: 8.2,
profit_growth: 12.5,
asset_growth: 5.6,
equity_growth: 6.8
},
// 财务概要
financial_summary: {
revenue: 162350,
net_profit: 52860,
total_assets: 5024560,
total_liabilities: 4698880
},
// 最新业绩预告
latest_forecast: {
forecast_type: '预增',
content: '预计全年净利润同比增长10%-17%'
}
},
// 资产负债表
balanceSheet: periods.map((period, i) => ({
// 资产负债表 - 嵌套结构8期数据
balanceSheet: statementPeriods.map((period, i) => ({
period,
total_assets: 5024560 - i * 50000, // 百万元
total_liabilities: 4698880 - i * 48000,
shareholders_equity: 325680 - i * 2000,
current_assets: 2512300 - i * 25000,
non_current_assets: 2512260 - i * 25000,
current_liabilities: 3456780 - i * 35000,
non_current_liabilities: 1242100 - i * 13000
assets: {
current_assets: {
cash: 856780 - i * 10000,
trading_financial_assets: 234560 - i * 5000,
notes_receivable: 12340 - i * 200,
accounts_receivable: 45670 - i * 1000,
prepayments: 8900 - i * 100,
other_receivables: 23450 - i * 500,
inventory: 156780 - i * 3000,
contract_assets: 34560 - i * 800,
other_current_assets: 67890 - i * 1500,
total: 2512300 - i * 25000
},
non_current_assets: {
long_term_equity_investments: 234560 - i * 5000,
investment_property: 45670 - i * 1000,
fixed_assets: 678900 - i * 15000,
construction_in_progress: 123450 - i * 3000,
right_of_use_assets: 34560 - i * 800,
intangible_assets: 89012 - i * 2000,
goodwill: 45670 - i * 1000,
deferred_tax_assets: 12340 - i * 300,
other_non_current_assets: 67890 - i * 1500,
total: 2512260 - i * 25000
},
total: 5024560 - i * 50000
},
liabilities: {
current_liabilities: {
short_term_borrowings: 456780 - i * 10000,
notes_payable: 23450 - i * 500,
accounts_payable: 234560 - i * 5000,
advance_receipts: 12340 - i * 300,
contract_liabilities: 34560 - i * 800,
employee_compensation_payable: 45670 - i * 1000,
taxes_payable: 23450 - i * 500,
other_payables: 78900 - i * 1500,
non_current_liabilities_due_within_one_year: 89012 - i * 2000,
total: 3456780 - i * 35000
},
non_current_liabilities: {
long_term_borrowings: 678900 - i * 15000,
bonds_payable: 234560 - i * 5000,
lease_liabilities: 45670 - i * 1000,
deferred_tax_liabilities: 12340 - i * 300,
other_non_current_liabilities: 89012 - i * 2000,
total: 1242100 - i * 13000
},
total: 4698880 - i * 48000
},
equity: {
share_capital: 19405,
capital_reserve: 89012 - i * 2000,
surplus_reserve: 45670 - i * 1000,
undistributed_profit: 156780 - i * 3000,
treasury_stock: 0,
other_comprehensive_income: 12340 - i * 300,
parent_company_equity: 315680 - i * 1800,
minority_interests: 10000 - i * 200,
total: 325680 - i * 2000
}
})),
// 利润表
incomeStatement: periods.map((period, i) => ({
// 利润表 - 嵌套结构8期数据
incomeStatement: statementPeriods.map((period, i) => ({
period,
revenue: 162350 - i * 4000, // 百万元
operating_cost: 45620 - i * 1200,
gross_profit: 116730 - i * 2800,
operating_profit: 68450 - i * 1500,
net_profit: 52860 - i * 1200,
eps: 2.72 - i * 0.06
revenue: {
total_operating_revenue: 162350 - i * 4000,
operating_revenue: 158900 - i * 3900,
other_income: 3450 - i * 100
},
costs: {
total_operating_cost: 93900 - i * 2500,
operating_cost: 45620 - i * 1200,
taxes_and_surcharges: 4560 - i * 100,
selling_expenses: 12340 - i * 300,
admin_expenses: 15670 - i * 400,
rd_expenses: 8900 - i * 200,
financial_expenses: 6810 - i * 300,
interest_expense: 8900 - i * 200,
interest_income: 2090 - i * 50,
three_expenses_total: 34820 - i * 1000,
four_expenses_total: 43720 - i * 1200,
asset_impairment_loss: 1200 - i * 50,
credit_impairment_loss: 2340 - i * 100
},
other_gains: {
fair_value_change: 1230 - i * 50,
investment_income: 3450 - i * 100,
investment_income_from_associates: 890 - i * 20,
exchange_income: 560 - i * 10,
asset_disposal_income: 340 - i * 10
},
profit: {
operating_profit: 68450 - i * 1500,
total_profit: 69500 - i * 1500,
income_tax_expense: 16640 - i * 300,
net_profit: 52860 - i * 1200,
parent_net_profit: 51200 - i * 1150,
minority_profit: 1660 - i * 50,
continuing_operations_net_profit: 52860 - i * 1200,
discontinued_operations_net_profit: 0
},
non_operating: {
non_operating_income: 1050 - i * 20,
non_operating_expenses: 450 - i * 10
},
per_share: {
basic_eps: 2.72 - i * 0.06,
diluted_eps: 2.70 - i * 0.06
},
comprehensive_income: {
other_comprehensive_income: 890 - i * 20,
total_comprehensive_income: 53750 - i * 1220,
parent_comprehensive_income: 52050 - i * 1170,
minority_comprehensive_income: 1700 - i * 50
}
})),
// 现金流量表
cashflow: periods.map((period, i) => ({
// 现金流量表 - 嵌套结构8期数据
cashflow: statementPeriods.map((period, i) => ({
period,
operating_cashflow: 125600 - i * 3000, // 百万元
investing_cashflow: -45300 - i * 1000,
financing_cashflow: -38200 + i * 500,
net_cashflow: 42100 - i * 1500,
cash_ending: 456780 - i * 10000
operating_activities: {
inflow: {
cash_from_sales: 178500 - i * 4500
},
outflow: {
cash_for_goods: 52900 - i * 1500
},
net_flow: 125600 - i * 3000
},
investment_activities: {
net_flow: -45300 - i * 1000
},
financing_activities: {
net_flow: -38200 + i * 500
},
cash_changes: {
net_increase: 42100 - i * 1500,
ending_balance: 456780 - i * 10000
},
key_metrics: {
free_cash_flow: 80300 - i * 2000
}
})),
// 财务指标
financialMetrics: periods.map((period, i) => ({
// 财务指标 - 嵌套结构12期数据
financialMetrics: metricsPeriods.map((period, i) => ({
period,
roe: 16.23 - i * 0.3, // %
roa: 1.05 - i * 0.02,
gross_margin: 71.92 - i * 0.5,
net_margin: 32.56 - i * 0.3,
current_ratio: 0.73 + i * 0.01,
quick_ratio: 0.71 + i * 0.01,
debt_ratio: 93.52 + i * 0.05,
asset_turnover: 0.41 - i * 0.01,
inventory_turnover: 0, // 银行无库存
receivable_turnover: 0 // 银行特殊
profitability: {
roe: 16.23 - i * 0.3,
roe_deducted: 15.89 - i * 0.3,
roe_weighted: 16.45 - i * 0.3,
roa: 1.05 - i * 0.02,
gross_margin: 71.92 - i * 0.5,
net_profit_margin: 32.56 - i * 0.3,
operating_profit_margin: 42.16 - i * 0.4,
cost_profit_ratio: 115.8 - i * 1.2,
ebit: 86140 - i * 1800
},
per_share_metrics: {
eps: 2.72 - i * 0.06,
basic_eps: 2.72 - i * 0.06,
diluted_eps: 2.70 - i * 0.06,
deducted_eps: 2.65 - i * 0.06,
bvps: 16.78 - i * 0.1,
operating_cash_flow_ps: 6.47 - i * 0.15,
capital_reserve_ps: 4.59 - i * 0.1,
undistributed_profit_ps: 8.08 - i * 0.15
},
growth: {
revenue_growth: 8.2 - i * 0.5,
net_profit_growth: 12.5 - i * 0.8,
deducted_profit_growth: 11.8 - i * 0.7,
parent_profit_growth: 12.3 - i * 0.75,
operating_cash_flow_growth: 15.6 - i * 1.0,
total_asset_growth: 5.6 - i * 0.3,
equity_growth: 6.8 - i * 0.4,
fixed_asset_growth: 4.2 - i * 0.2
},
operational_efficiency: {
total_asset_turnover: 0.41 - i * 0.01,
fixed_asset_turnover: 2.35 - i * 0.05,
current_asset_turnover: 0.82 - i * 0.02,
receivable_turnover: 12.5 - i * 0.3,
receivable_days: 29.2 + i * 0.7,
inventory_turnover: 0, // 银行无库存
inventory_days: 0,
working_capital_turnover: 1.68 - i * 0.04
},
solvency: {
current_ratio: 0.73 + i * 0.01,
quick_ratio: 0.71 + i * 0.01,
cash_ratio: 0.25 + i * 0.005,
conservative_quick_ratio: 0.68 + i * 0.01,
asset_liability_ratio: 93.52 + i * 0.05,
interest_coverage: 8.56 - i * 0.2,
cash_to_maturity_debt_ratio: 0.45 - i * 0.01,
tangible_asset_debt_ratio: 94.12 + i * 0.05
},
expense_ratios: {
selling_expense_ratio: 7.60 + i * 0.1,
admin_expense_ratio: 9.65 + i * 0.1,
financial_expense_ratio: 4.19 + i * 0.1,
rd_expense_ratio: 5.48 + i * 0.1,
three_expense_ratio: 21.44 + i * 0.3,
four_expense_ratio: 26.92 + i * 0.4,
cost_ratio: 28.10 + i * 0.2
}
})),
// 主营业务
// 主营业务 - 按产品/业务分类
mainBusiness: {
by_product: [
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
product_classification: [
{
period: '2024-09-30',
report_type: '2024年三季报',
products: [
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
{ content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
]
},
{
period: '2024-06-30',
report_type: '2024年中报',
products: [
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
{ content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
]
},
{
period: '2024-03-31',
report_type: '2024年一季报',
products: [
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
{ content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
]
},
{
period: '2023-12-31',
report_type: '2023年年报',
products: [
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
{ content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
]
},
],
by_region: [
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
industry_classification: [
{
period: '2024-09-30',
report_type: '2024年三季报',
industries: [
{ content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 },
{ content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 },
{ content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 },
{ content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 },
{ content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 },
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
]
},
{
period: '2024-06-30',
report_type: '2024年中报',
industries: [
{ content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 },
{ content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 },
{ content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 },
{ content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 },
{ content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 },
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
]
},
]
},
@@ -92,48 +356,74 @@ export const generateFinancialData = (stockCode) => {
publish_date: '2024-10-15'
},
// 行业排名
industryRank: {
industry: '银行',
total_companies: 42,
rankings: [
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
]
},
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
industryRank: [
{
period: '2024-09-30',
report_type: '三季报',
rankings: [
{
industry_name: stockCode === '000001' ? '银行' : '制造业',
level_description: '一级行业',
metrics: {
eps: { value: 2.72, rank: 8, industry_avg: 1.85 },
bvps: { value: 15.23, rank: 12, industry_avg: 12.50 },
roe: { value: 16.23, rank: 10, industry_avg: 12.00 },
revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 },
profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 },
operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 },
debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 },
receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 }
}
}
]
}
],
// 期间对比
periodComparison: {
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
metrics: [
{
name: '营业收入',
unit: '百万元',
values: [41500, 40800, 40200, 40850],
yoy: [8.2, 7.8, 8.5, 9.2]
},
{
name: '净利润',
unit: '百万元',
values: [13420, 13180, 13050, 13210],
yoy: [12.5, 11.2, 10.8, 12.3]
},
{
name: 'ROE',
unit: '%',
values: [16.23, 15.98, 15.75, 16.02],
yoy: [1.2, 0.8, 0.5, 1.0]
},
{
name: 'EPS',
unit: '元',
values: [0.69, 0.68, 0.67, 0.68],
yoy: [12.3, 11.5, 10.5, 12.0]
// 期间对比 - 营收与利润趋势数据
periodComparison: [
{
period: '2024-09-30',
performance: {
revenue: 41500000000, // 415亿
net_profit: 13420000000 // 134.2亿
}
]
}
},
{
period: '2024-06-30',
performance: {
revenue: 40800000000, // 408亿
net_profit: 13180000000 // 131.8亿
}
},
{
period: '2024-03-31',
performance: {
revenue: 40200000000, // 402亿
net_profit: 13050000000 // 130.5亿
}
},
{
period: '2023-12-31',
performance: {
revenue: 40850000000, // 408.5亿
net_profit: 13210000000 // 132.1亿
}
},
{
period: '2023-09-30',
performance: {
revenue: 38500000000, // 385亿
net_profit: 11920000000 // 119.2亿
}
},
{
period: '2023-06-30',
performance: {
revenue: 37800000000, // 378亿
net_profit: 11850000000 // 118.5亿
}
}
]
};
};

View File

@@ -1,9 +1,22 @@
// src/mocks/data/market.js
// 市场行情相关的 Mock 数据
// 股票名称映射
const STOCK_NAME_MAP = {
'000001': { name: '平安银行', basePrice: 13.50 },
'600000': { name: '浦发银行', basePrice: 8.20 },
'600519': { name: '贵州茅台', basePrice: 1650.00 },
'000858': { name: '五粮液', basePrice: 165.00 },
'601318': { name: '中国平安', basePrice: 45.00 },
'600036': { name: '招商银行', basePrice: 32.00 },
'300750': { name: '宁德时代', basePrice: 180.00 },
'002594': { name: '比亚迪', basePrice: 260.00 },
};
// 生成市场数据
export const generateMarketData = (stockCode) => {
const basePrice = 13.50; // 基准价格平安银行约13.5元)
const stockInfo = STOCK_NAME_MAP[stockCode] || { name: `股票${stockCode}`, basePrice: 20.00 };
const basePrice = stockInfo.basePrice;
return {
stockCode,
@@ -24,8 +37,9 @@ export const generateMarketData = (stockCode) => {
low: parseFloat(low.toFixed(2)),
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3%
pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7
};
})
},
@@ -41,73 +55,118 @@ export const generateMarketData = (stockCode) => {
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
},
securities: {
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额(股数)
balance_amount: Math.floor(Math.random() * 2000000000) + 1000000000, // 融券余额(金额)
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
}
}))
},
// 大单统计 - 包含 daily_stats 数组
// 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型
bigDealData: {
success: true,
data: [],
daily_stats: Array(10).fill(null).map((_, i) => ({
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
small_sell: Math.floor(Math.random() * 100000000) + 25000000
}))
daily_stats: Array(10).fill(null).map((_, i) => {
const count = Math.floor(Math.random() * 5) + 1; // 1-5 笔交易
const avgPrice = parseFloat((basePrice * (0.95 + Math.random() * 0.1)).toFixed(2)); // 折价/溢价 -5%~+5%
const deals = Array(count).fill(null).map(() => {
const volume = parseFloat((Math.random() * 500 + 100).toFixed(2)); // 100-600 万股
const price = parseFloat((avgPrice * (0.98 + Math.random() * 0.04)).toFixed(2));
return {
buyer_dept: ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司'][Math.floor(Math.random() * 4)],
seller_dept: ['中金公司北京营业部', '海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司'][Math.floor(Math.random() * 4)],
price,
volume,
amount: parseFloat((price * volume).toFixed(2))
};
});
const totalVolume = deals.reduce((sum, d) => sum + d.volume, 0);
const totalAmount = deals.reduce((sum, d) => sum + d.amount, 0);
return {
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
count,
total_volume: parseFloat(totalVolume.toFixed(2)),
total_amount: parseFloat(totalAmount.toFixed(2)),
avg_price: avgPrice,
deals
};
})
},
// 异动分析 - 包含 grouped_data 数组
// 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型
unusualData: {
success: true,
data: [],
grouped_data: Array(5).fill(null).map((_, i) => ({
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
events: [
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
],
count: 3
}))
grouped_data: Array(5).fill(null).map((_, i) => {
const buyerDepts = ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司', '中金公司北京营业部'];
const sellerDepts = ['海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司', '东方证券上海营业部', '兴业证券福州营业部'];
const infoTypes = ['日涨幅偏离值达7%', '日振幅达15%', '连续三日涨幅偏离20%', '换手率达20%'];
const buyers = buyerDepts.map(dept => ({
dept_name: dept,
buy_amount: Math.floor(Math.random() * 50000000) + 10000000 // 1000万-6000万
})).sort((a, b) => b.buy_amount - a.buy_amount);
const sellers = sellerDepts.map(dept => ({
dept_name: dept,
sell_amount: Math.floor(Math.random() * 40000000) + 8000000 // 800万-4800万
})).sort((a, b) => b.sell_amount - a.sell_amount);
const totalBuy = buyers.reduce((sum, b) => sum + b.buy_amount, 0);
const totalSell = sellers.reduce((sum, s) => sum + s.sell_amount, 0);
return {
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
total_buy: totalBuy,
total_sell: totalSell,
net_amount: totalBuy - totalSell,
buyers,
sellers,
info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型
};
})
},
// 股权质押
// 股权质押 - 匹配 PledgeData[] 类型
pledgeData: {
success: true,
data: {
total_pledged: 25.6, // 质押比例%
major_shareholders: [
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
],
update_date: '2024-09-30'
}
data: Array(12).fill(null).map((_, i) => {
const date = new Date();
date.setMonth(date.getMonth() - (11 - i));
return {
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000,
total_pledge: Math.floor(Math.random() * 1200000000) + 550000000,
total_shares: 19405918198,
pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9%
pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150
};
})
},
// 市场摘要
// 市场摘要 - 匹配 MarketSummary 类型
summaryData: {
success: true,
data: {
current_price: basePrice,
change: 0.25,
change_pct: 1.89,
open: 13.35,
high: 13.68,
low: 13.28,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96,
pb_ratio: 0.72,
total_market_cap: 262300000000,
circulating_market_cap: 262300000000
stock_code: stockCode,
stock_name: stockInfo.name,
latest_trade: {
close: basePrice,
change_percent: 1.89,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96
},
latest_funding: {
financing_balance: 5823000000,
securities_balance: 125600000
},
latest_pledge: {
pledge_ratio: 8.25
}
}
},
@@ -131,26 +190,57 @@ export const generateMarketData = (stockCode) => {
})
},
// 最新分时数据
// 最新分时数据 - 匹配 MinuteData 类型
latestMinuteData: {
success: true,
data: Array(240).fill(null).map((_, i) => {
const minute = 9 * 60 + 30 + i; // 从9:30开始
const hour = Math.floor(minute / 60);
const min = minute % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
return {
time,
price: (basePrice + randomChange).toFixed(2),
volume: Math.floor(Math.random() * 2000000) + 500000,
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
};
}),
data: (() => {
const minuteData = [];
// 上午 9:30-11:30 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 9 + Math.floor((30 + i) / 60);
const min = (30 + i) % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
open,
close,
high,
low,
volume: Math.floor(Math.random() * 2000000) + 500000,
amount: Math.floor(Math.random() * 30000000) + 5000000
});
}
// 下午 13:00-15:00 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 13 + Math.floor(i / 60);
const min = i % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
open,
close,
high,
low,
volume: Math.floor(Math.random() * 1500000) + 400000,
amount: Math.floor(Math.random() * 25000000) + 4000000
});
}
return minuteData;
})(),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
name: stockInfo.name,
trade_date: new Date().toISOString().split('T')[0],
type: 'minute'
type: '1min'
}
};
};

View File

@@ -351,15 +351,21 @@ export const accountHandlers = [
const body = await request.json();
console.log('[Mock] 创建投资计划:', body);
// 生成唯一 ID使用时间戳避免冲突
const newId = Date.now();
const newPlan = {
id: mockInvestmentPlans.length + 301,
id: newId,
user_id: currentUser.id,
...body,
// 确保 target_date 字段存在(兼容前端发送的 date 字段)
target_date: body.target_date || body.date,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
mockInvestmentPlans.push(newPlan);
console.log('[Mock] 新增计划/复盘,当前总数:', mockInvestmentPlans.length);
return HttpResponse.json({
success: true,
@@ -488,13 +494,29 @@ export const accountHandlers = [
});
}
// 5. 按日期倒序排序(最新的在前面)
filteredEvents.sort((a, b) => {
const dateA = new Date(a.date || a.event_date);
const dateB = new Date(b.date || b.event_date);
return dateB - dateA; // 倒序:新日期在前
});
// 打印今天的事件(方便调试)
const today = new Date().toISOString().split('T')[0];
const todayEvents = filteredEvents.filter(e =>
(e.date === today || e.event_date === today)
);
console.log('[Mock] 日历事件详情:', {
currentUserId: currentUser.id,
calendarEvents: calendarEvents.length,
investmentPlansAsEvents: investmentPlansAsEvents.length,
total: filteredEvents.length,
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
reviewsCount: filteredEvents.filter(e => e.type === 'review').length
reviewsCount: filteredEvents.filter(e => e.type === 'review').length,
today,
todayEventsCount: todayEvents.length,
todayEventTitles: todayEvents.map(e => `[${e.type}] ${e.title}`)
});
return HttpResponse.json({

View File

@@ -1,16 +1,28 @@
// src/mocks/handlers/bytedesk.js
/**
* Bytedesk 客服 Widget MSW Handler
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
* Mock 模式下返回模拟数据
*/
import { http, passthrough } from 'msw';
import { http, HttpResponse, passthrough } from 'msw';
export const bytedeskHandlers = [
// Bytedesk API 请求 - 直接 passthrough
// 匹配 /bytedesk/* 路径(通过代理访问后端)
// 未读消息数量
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
return HttpResponse.json({
code: 200,
message: 'success',
data: { count: 0 },
});
}),
// 其他 Bytedesk API - 返回通用成功响应
http.all('/bytedesk/*', () => {
return passthrough();
return HttpResponse.json({
code: 200,
message: 'success',
data: null,
});
}),
// Bytedesk 外部 CDN/服务请求

View File

@@ -43,12 +43,10 @@ export const companyHandlers = [
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline
return HttpResponse.json({
success: true,
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
data: data.keyFactorsTimeline
});
}),
@@ -69,10 +67,14 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.actualControl;
// 数据保持原始百分比格式(如 52.38 表示 52.38%
const formatted = Array.isArray(raw) ? raw : [];
return HttpResponse.json({
success: true,
data: data.actualControl
data: formatted
});
}),
@@ -81,10 +83,14 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.concentration;
// 数据保持原始百分比格式(如 52.38 表示 52.38%
const formatted = Array.isArray(raw) ? raw : [];
return HttpResponse.json({
success: true,
data: data.concentration
data: formatted
});
}),

View File

@@ -1009,6 +1009,24 @@ export const conceptHandlers = [
{ id: 'lv2_15_1', name: '国际贸易', concept_count: 15, concepts: ['跨境电商', '出口', '贸易摩擦', '人民币国际化', '中美贸易', '中欧贸易', '东盟贸易'] },
{ id: 'lv2_15_2', name: '宏观主题', concept_count: 10, concepts: ['美联储加息', '美债', '汇率', '通胀', '衰退预期', '地缘政治'] }
]
},
{
id: 'lv1_16',
name: '传统能源与资源',
concept_count: 30,
children: [
{ id: 'lv2_16_1', name: '煤炭石油', concept_count: 15, concepts: ['煤炭', '动力煤', '焦煤', '石油', '天然气', '页岩油', '油服', '油气开采', '煤化工', '石油化工'] },
{ id: 'lv2_16_2', name: '钢铁建材', concept_count: 15, concepts: ['钢铁', '特钢', '铁矿石', '水泥', '玻璃', '建材', '基建', '房地产', '装配式建筑'] }
]
},
{
id: 'lv1_17',
name: '公用事业与交运',
concept_count: 25,
children: [
{ id: 'lv2_17_1', name: '公用事业', concept_count: 12, concepts: ['电力', '水务', '燃气', '环保', '垃圾处理', '污水处理', '园林绿化'] },
{ id: 'lv2_17_2', name: '交通运输', concept_count: 13, concepts: ['航空', '机场', '港口', '航运', '铁路', '公路', '物流', '快递', '冷链物流'] }
]
}
];

View File

@@ -120,9 +120,14 @@ export const eventHandlers = [
try {
const result = generateMockEvents(params);
// 返回格式兼容 useEventData 期望的结构
// useEventData 期望: { success, data: { events: [], pagination: {} } }
return HttpResponse.json({
success: true,
data: result,
data: {
events: result.events, // 事件数组
pagination: result.pagination // 分页信息
},
message: '获取成功'
});
} catch (error) {
@@ -136,16 +141,14 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_prev: false,
has_next: false
}
},
{ status: 500 }
@@ -254,6 +257,159 @@ export const eventHandlers = [
}
}),
// ==================== 主线模式相关(必须在 :eventId 之前,否则会被通配符匹配)====================
// 获取按主线lv1/lv2/lv3概念分组的事件列表
http.get('/api/events/mainline', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const recentDays = parseInt(url.searchParams.get('recent_days') || '7', 10);
const importance = url.searchParams.get('importance') || 'all';
const limitPerMainline = parseInt(url.searchParams.get('limit') || '20', 10);
const groupBy = url.searchParams.get('group_by') || 'lv2';
try {
// 生成 mock 事件数据 - 第一个参数是 timeRangenull 表示默认24小时第二个参数是 count
const allEvents = generateDynamicNewsEvents(null, 100);
const mainlineDefinitions = [
{ lv3_id: 'L3_AI_CHIP', lv3_name: 'AI芯片与算力', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['算力', 'AI芯片', 'GPU', '英伟达', '华为昇腾', '寒武纪'] },
{ lv3_id: 'L3_AI_SERVER', lv3_name: '服务器与数据中心', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['服务器', '数据中心', '智算中心', '液冷'] },
{ lv3_id: 'L3_OPTICAL', lv3_name: '光通信与CPO', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['CPO', '光通信', '光模块', '光芯片'] },
{ lv3_id: 'L3_PCB', lv3_name: 'PCB与封装', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['PCB', '封装', 'AI PCB'] },
{ lv3_id: 'L3_AI_APP', lv3_name: 'AI应用与大模型', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['大模型', '智能体', 'AI', '人工智能', 'DeepSeek', 'KIMI', 'ChatGPT'] },
{ lv3_id: 'L3_CHIP_DESIGN', lv3_name: '芯片设计', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['芯片设计', '半导体', 'IC设计'] },
{ lv3_id: 'L3_CHIP_MFG', lv3_name: '芯片制造', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['晶圆', '光刻', '芯片制造', '中芯国际'] },
{ lv3_id: 'L3_HUMANOID', lv3_name: '人形机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['人形机器人', '具身智能', '特斯拉机器人'] },
{ lv3_id: 'L3_INDUSTRIAL_ROBOT', lv3_name: '工业机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['工业机器人', '自动化', '机器人'] },
{ lv3_id: 'L3_MOBILE', lv3_name: '智能手机', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['手机', '华为', '苹果', '小米', '折叠屏'] },
{ lv3_id: 'L3_XR', lv3_name: 'XR与可穿戴', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['XR', 'VR', 'AR', '可穿戴', 'MR', 'Vision Pro'] },
{ lv3_id: 'L3_5G', lv3_name: '5G/6G通信', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['5G', '6G', '通信', '基站'] },
{ lv3_id: 'L3_CLOUD', lv3_name: '云计算与软件', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['云计算', '软件', 'SaaS', '互联网', '数字化'] },
{ lv3_id: 'L3_PV', lv3_name: '光伏', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['光伏', '太阳能', '硅片', '组件'] },
{ lv3_id: 'L3_STORAGE', lv3_name: '储能与电池', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['储能', '电池', '锂电', '固态电池', '新能源'] },
{ lv3_id: 'L3_EV_OEM', lv3_name: '新能源整车', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['新能源汽车', '电动车', '比亚迪', '特斯拉', '整车'] },
{ lv3_id: 'L3_AUTO_DRIVE', lv3_name: '智能驾驶', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['智能驾驶', '自动驾驶', '智能网联', '车路协同'] },
{ lv3_id: 'L3_DRONE', lv3_name: '无人机', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['无人机', '低空', '空域'] },
{ lv3_id: 'L3_EVTOL', lv3_name: 'eVTOL', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['eVTOL', '飞行汽车', '空中出租车'] },
{ lv3_id: 'L3_AEROSPACE', lv3_name: '航空航天', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['航空', '航天', '卫星', '火箭', '军工'] },
{ lv3_id: 'L3_DEFENSE', lv3_name: '国防军工', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['国防', '导弹', '军工装备'] },
{ lv3_id: 'L3_DRUG', lv3_name: '创新药', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['创新药', '医药', '生物', 'CXO'] },
{ lv3_id: 'L3_DEVICE', lv3_name: '医疗器械', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['医疗器械', '医疗', '器械'] },
{ lv3_id: 'L3_BANK', lv3_name: '银行', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['银行', '金融'] },
{ lv3_id: 'L3_BROKER', lv3_name: '券商', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['券商', '证券'] },
];
const hierarchyOptions = {
lv1: [...new Map(mainlineDefinitions.map(m => [m.lv1_id, { id: m.lv1_id, name: m.lv1_name }])).values()],
lv2: [...new Map(mainlineDefinitions.map(m => [m.lv2_id, { id: m.lv2_id, name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name }])).values()],
lv3: mainlineDefinitions.map(m => ({ id: m.lv3_id, name: m.lv3_name, lv2_id: m.lv2_id, lv2_name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name })),
};
const mainlineGroups = {};
const isSpecificId = groupBy.startsWith('L1_') || groupBy.startsWith('L2_') || groupBy.startsWith('L3_');
allEvents.forEach(event => {
const keywords = event.keywords || event.related_concepts || [];
const conceptNames = keywords.map(k => {
if (typeof k === 'string') return k;
if (typeof k === 'object' && k !== null) return k.concept || k.name || '';
return '';
}).filter(Boolean).join(' ');
const titleAndDesc = `${event.title || ''} ${event.description || ''}`;
const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase();
mainlineDefinitions.forEach(mainline => {
const matched = mainline.keywords.some(kw => textToMatch.includes(kw.toLowerCase()));
if (matched) {
let groupKey, groupData;
if (isSpecificId) {
if (groupBy.startsWith('L1_') && mainline.lv1_id === groupBy) {
groupKey = mainline.lv2_id;
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
} else if (groupBy.startsWith('L2_') && mainline.lv2_id === groupBy) {
groupKey = mainline.lv3_id;
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
} else if (groupBy.startsWith('L3_') && mainline.lv3_id === groupBy) {
groupKey = mainline.lv3_id;
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
} else {
return;
}
} else if (groupBy === 'lv1') {
groupKey = mainline.lv1_id;
groupData = { group_id: mainline.lv1_id, group_name: mainline.lv1_name, events: [] };
} else if (groupBy === 'lv3') {
groupKey = mainline.lv3_id;
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
} else {
groupKey = mainline.lv2_id;
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
}
if (!mainlineGroups[groupKey]) {
mainlineGroups[groupKey] = groupData;
}
if (!mainlineGroups[groupKey].events.find(e => e.id === event.id)) {
mainlineGroups[groupKey].events.push(event);
}
}
});
});
const generatePriceData = () => parseFloat((Math.random() * 13 - 5).toFixed(2));
const priceDataMap = { lv1: {}, lv2: {}, lv3: {} };
mainlineDefinitions.forEach(def => {
if (!priceDataMap.lv1[def.lv1_name]) priceDataMap.lv1[def.lv1_name] = generatePriceData();
if (!priceDataMap.lv2[def.lv2_name]) priceDataMap.lv2[def.lv2_name] = generatePriceData();
if (!priceDataMap.lv3[def.lv3_name]) priceDataMap.lv3[def.lv3_name] = generatePriceData();
});
const mainlines = Object.values(mainlineGroups)
.map(group => {
let avgChangePct = null;
if (groupBy === 'lv1' || groupBy.startsWith('L1_')) {
avgChangePct = groupBy.startsWith('L1_') ? priceDataMap.lv2[group.group_name] : priceDataMap.lv1[group.group_name];
} else if (groupBy === 'lv3' || groupBy.startsWith('L2_')) {
avgChangePct = priceDataMap.lv3[group.group_name];
} else {
avgChangePct = priceDataMap.lv2[group.group_name];
}
return {
...group,
events: group.events.slice(0, limitPerMainline),
event_count: Math.min(group.events.length, limitPerMainline),
avg_change_pct: avgChangePct ?? null,
price_date: new Date().toISOString().split('T')[0]
};
})
.filter(group => group.event_count > 0)
.sort((a, b) => b.event_count - a.event_count);
const groupedEventIds = new Set();
mainlines.forEach(m => m.events.forEach(e => groupedEventIds.add(e.id)));
const ungroupedCount = allEvents.filter(e => !groupedEventIds.has(e.id)).length;
return HttpResponse.json({
success: true,
data: {
mainlines,
total_events: allEvents.length,
mainline_count: mainlines.length,
ungrouped_count: ungroupedCount,
group_by: groupBy,
hierarchy_options: hierarchyOptions,
}
});
} catch (error) {
console.error('[Mock Event] 主线数据获取失败:', error);
return HttpResponse.json({ success: false, error: error.message || '获取主线数据失败' }, { status: 500 });
}
}),
// ==================== 事件详情相关 ====================
// 获取事件详情

View File

@@ -512,7 +512,55 @@ export const marketHandlers = [
});
}),
// 12. 市场统计数据(个股中心页面使用)
// 12. 市场概况数据(投资仪表盘使用)- 上证/深证/总市值/成交额
http.get('/api/market/summary', async () => {
await delay(150);
// 生成实时数据(基于当前时间产生小波动)
const now = new Date();
const seed = now.getHours() * 60 + now.getMinutes();
// 上证指数(基准 3400
const shBasePrice = 3400;
const shChange = parseFloat(((Math.sin(seed / 30) + Math.random() - 0.5) * 2).toFixed(2));
const shPrice = parseFloat((shBasePrice * (1 + shChange / 100)).toFixed(2));
const shChangeAmount = parseFloat((shPrice - shBasePrice).toFixed(2));
// 深证指数(基准 10800
const szBasePrice = 10800;
const szChange = parseFloat(((Math.sin(seed / 25) + Math.random() - 0.5) * 2.5).toFixed(2));
const szPrice = parseFloat((szBasePrice * (1 + szChange / 100)).toFixed(2));
const szChangeAmount = parseFloat((szPrice - szBasePrice).toFixed(2));
// 总市值(约 100-110 万亿波动)
const totalMarketCap = parseFloat((105 + (Math.sin(seed / 60) * 5)).toFixed(1)) * 1000000000000;
// 成交额(约 0.8-1.5 万亿波动)
const turnover = parseFloat((1.0 + (Math.random() * 0.5 - 0.2)).toFixed(2)) * 1000000000000;
console.log('[Mock Market] 获取市场概况数据');
return HttpResponse.json({
success: true,
data: {
shanghai: {
value: shPrice,
change: shChange,
changeAmount: shChangeAmount,
},
shenzhen: {
value: szPrice,
change: szChange,
changeAmount: szChangeAmount,
},
totalMarketCap,
turnover,
updateTime: now.toISOString(),
},
});
}),
// 13. 市场统计数据(个股中心页面使用)
http.get('/api/market/statistics', async ({ request }) => {
await delay(200);
const url = new URL(request.url);

View File

@@ -263,15 +263,15 @@ export const stockHandlers = [
try {
let data;
if (type === 'timeline') {
if (type === 'timeline' || type === 'minute') {
// timeline 和 minute 都使用分时数据
data = generateTimelineData(indexCode);
} else if (type === 'daily') {
data = generateDailyData(indexCode, 30);
} else {
return HttpResponse.json(
{ error: '不支持的类型' },
{ status: 400 }
);
// 其他类型也降级使用 timeline 数据
console.log('[Mock Stock] 未知类型,降级使用 timeline:', type);
data = generateTimelineData(indexCode);
}
return HttpResponse.json({
@@ -387,6 +387,68 @@ export const stockHandlers = [
}
}),
// 获取股票业绩预告
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
const stockName = stockInfo?.name || `股票${stockCode}`;
// 业绩预告类型列表
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
// 生成业绩预告数据
const forecasts = [
{
forecast_type: '预增',
report_date: '2024年年报',
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元同比增长10%至17%。`,
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
change_range: {
lower: 10,
upper: 17
},
publish_date: '2024-10-15'
},
{
forecast_type: '略增',
report_date: '2024年三季报',
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元同比增长5%至12%。`,
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
change_range: {
lower: 5,
upper: 12
},
publish_date: '2024-07-12'
},
{
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
report_date: '2024年中报',
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
change_range: {
lower: 3,
upper: 8
},
publish_date: '2024-04-20'
}
];
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
forecasts: forecasts
}
});
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
@@ -414,6 +476,25 @@ export const stockHandlers = [
stockMap[s.code] = s.name;
});
// 行业和指数映射表
const stockIndustryMap = {
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
};
const defaultIndustries = [
{ industry_l1: '科技', industry: '软件' },
{ industry_l1: '医药', industry: '化学制药' },
{ industry_l1: '消费', industry: '食品' },
{ industry_l1: '金融', industry: '证券' },
{ industry_l1: '工业', industry: '机械' },
];
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
@@ -426,6 +507,11 @@ export const stockHandlers = [
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
// 获取行业和指数信息
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
@@ -439,7 +525,23 @@ export const stockHandlers = [
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
update_time: new Date().toISOString(),
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || [],
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
// 主力动态
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
};
});
@@ -456,4 +558,133 @@ export const stockHandlers = [
);
}
}),
// 获取股票详细行情quote-detail
http.get('/api/stock/:stockCode/quote-detail', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取股票详细行情:', { stockCode });
const stocks = generateStockList();
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const stockInfo = stocks.find(s => s.code === codeWithoutSuffix);
const stockName = stockInfo?.name || `股票${stockCode}`;
// 生成基础价格10-200之间
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
// 涨跌幅(-10% 到 +10%
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
// 涨跌额
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
price: basePrice,
change: change,
change_percent: changePercent,
prev_close: prevClose,
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
turnover_rate: parseFloat((Math.random() * 10).toFixed(2)),
amplitude: parseFloat((Math.random() * 8).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString(),
// 买卖盘口
bid1: parseFloat((basePrice * 0.998).toFixed(2)),
bid1_volume: Math.floor(Math.random() * 10000),
bid2: parseFloat((basePrice * 0.996).toFixed(2)),
bid2_volume: Math.floor(Math.random() * 10000),
bid3: parseFloat((basePrice * 0.994).toFixed(2)),
bid3_volume: Math.floor(Math.random() * 10000),
bid4: parseFloat((basePrice * 0.992).toFixed(2)),
bid4_volume: Math.floor(Math.random() * 10000),
bid5: parseFloat((basePrice * 0.990).toFixed(2)),
bid5_volume: Math.floor(Math.random() * 10000),
ask1: parseFloat((basePrice * 1.002).toFixed(2)),
ask1_volume: Math.floor(Math.random() * 10000),
ask2: parseFloat((basePrice * 1.004).toFixed(2)),
ask2_volume: Math.floor(Math.random() * 10000),
ask3: parseFloat((basePrice * 1.006).toFixed(2)),
ask3_volume: Math.floor(Math.random() * 10000),
ask4: parseFloat((basePrice * 1.008).toFixed(2)),
ask4_volume: Math.floor(Math.random() * 10000),
ask5: parseFloat((basePrice * 1.010).toFixed(2)),
ask5_volume: Math.floor(Math.random() * 10000),
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
circulating_market_cap: `${(Math.random() * 3000 + 50).toFixed(0)}亿`,
total_shares: `${(Math.random() * 100 + 10).toFixed(2)}亿`,
circulating_shares: `${(Math.random() * 80 + 5).toFixed(2)}亿`,
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
week52_low: parseFloat((basePrice * 0.7).toFixed(2))
},
message: '获取成功'
});
}),
// FlexScreen 行情数据
http.get('/api/flex-screen/quotes', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const codes = url.searchParams.get('codes')?.split(',') || [];
console.log('[Mock Stock] 获取 FlexScreen 行情:', { codes });
// 默认主要指数
const defaultIndices = ['000001', '399001', '399006'];
const targetCodes = codes.length > 0 ? codes : defaultIndices;
const indexData = {
'000001': { name: '上证指数', basePrice: 3200 },
'399001': { name: '深证成指', basePrice: 10500 },
'399006': { name: '创业板指', basePrice: 2100 },
'000300': { name: '沪深300', basePrice: 3800 },
'000016': { name: '上证50', basePrice: 2600 },
'000905': { name: '中证500', basePrice: 5800 },
};
const quotesData = {};
targetCodes.forEach(code => {
const codeWithoutSuffix = code.replace(/\.(SH|SZ)$/i, '');
const info = indexData[codeWithoutSuffix] || { name: `指数${code}`, basePrice: 3000 };
const changePercent = parseFloat((Math.random() * 4 - 2).toFixed(2));
const price = parseFloat((info.basePrice * (1 + changePercent / 100)).toFixed(2));
const change = parseFloat((price - info.basePrice).toFixed(2));
quotesData[code] = {
code: code,
name: info.name,
price: price,
change: change,
change_percent: changePercent,
prev_close: info.basePrice,
open: parseFloat((info.basePrice * (1 + (Math.random() * 0.01 - 0.005))).toFixed(2)),
high: parseFloat((price * (1 + Math.random() * 0.01)).toFixed(2)),
low: parseFloat((price * (1 - Math.random() * 0.01)).toFixed(2)),
volume: parseFloat((Math.random() * 5000 + 2000).toFixed(2)), // 亿手
amount: parseFloat((Math.random() * 8000 + 3000).toFixed(2)), // 亿元
update_time: new Date().toISOString()
};
});
return HttpResponse.json({
success: true,
data: quotesData,
message: '获取成功'
});
}),
];

View File

@@ -14,6 +14,7 @@ import theme from '../theme/theme.js';
// Contexts
import { AuthProvider } from '../contexts/AuthContext';
import { NotificationProvider } from '../contexts/NotificationContext';
import { GlobalSidebarProvider } from '../contexts/GlobalSidebarContext';
/**
* AppProviders - 应用的 Provider 容器
@@ -57,7 +58,9 @@ export function AppProviders({ children }) {
>
<NotificationProvider>
<AuthProvider>
{children}
<GlobalSidebarProvider>
{children}
</GlobalSidebarProvider>
</AuthProvider>
</NotificationProvider>
</ChakraProvider>

View File

@@ -28,7 +28,7 @@ export const homeRoutes = [
// 个人中心 - /home/center
{
path: 'center',
component: lazyComponents.CenterDashboard,
component: lazyComponents.Center,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '个人中心',

View File

@@ -11,7 +11,7 @@ export const lazyComponents = {
// Home 模块
// ⚡ 直接引用 HomePage无需中间层静态页面不需要骨架屏
HomePage: React.lazy(() => import('@views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
Center: React.lazy(() => import('@views/Center')),
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
// 价值论坛 - 我的积分页面
ForumMyPoints: React.lazy(() => import('@views/Profile')),
@@ -35,9 +35,9 @@ export const lazyComponents = {
// 公司相关模块
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('@views/AgentChat')),
@@ -56,7 +56,7 @@ export const lazyComponents = {
*/
export const {
HomePage,
CenterDashboard,
Center,
ProfilePage,
ForumMyPoints,
SettingsPage,

49
src/services/financialService.d.ts vendored Normal file
View File

@@ -0,0 +1,49 @@
// financialService 类型声明
export interface RequestOptions {
signal?: AbortSignal;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface FinancialService {
getStockInfo(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
getBalanceSheet(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getIncomeStatement(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getCashflow(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getFinancialMetrics(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getMainBusiness(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any>>;
getForecast(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
getIndustryRank(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getPeriodComparison(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
}
export const financialService: FinancialService;
export interface FormatUtils {
formatLargeNumber(num: number, decimal?: number): string;
formatPercent(num: number, decimal?: number): string;
formatDate(dateStr: string): string;
getReportType(dateStr: string): string;
getGrowthColor(value: number): string;
getTrendIcon(current: number, previous: number): 'up' | 'down' | 'stable';
calculateYoY(current: number, yearAgo: number): number | null;
calculateQoQ(current: number, previous: number): number | null;
getFinancialHealthScore(metrics: any): { score: number; level: string; color: string } | null;
getTableColumns(type: string): any[];
}
export const formatUtils: FormatUtils;
export interface ChartUtils {
prepareTrendData(data: any[], metrics: any[]): any[];
preparePieData(data: any[], valueKey: string, nameKey: string): any[];
prepareComparisonData(data: any[], periods: any[], metrics: any[]): any[];
getChartColors(theme?: string): string[];
}
export const chartUtils: ChartUtils;

View File

@@ -1,133 +1,137 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/financialService.js
/**
* 完整的财务数据服务层
* 对应Flask后端的所有财务API接口
*/
import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
import axios from '@utils/axiosConfig';
/**
* 统一的 API 请求函数
* axios 拦截器已自动处理日志记录
* @param {string} url - 请求 URL
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号,用于取消请求
*/
const apiRequest = async (url, options = {}) => {
try {
logger.debug('financialService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const { method = 'GET', body, signal, ...rest } = options;
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies以便后端识别登录状态
});
const config = {
method,
url,
signal,
...rest,
};
if (!response.ok) {
const errorText = await response.text();
logger.error('financialService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url,
status: response.status,
errorText: errorText.substring(0, 200)
});
throw new Error(`HTTP error! status: ${response.status}`);
// 如果有 body根据方法设置 data 或 params
if (body) {
if (method === 'GET') {
config.params = typeof body === 'string' ? JSON.parse(body) : body;
} else {
config.data = typeof body === 'string' ? JSON.parse(body) : body;
}
const data = await response.json();
logger.debug('financialService', 'API响应', {
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
logger.error('financialService', 'apiRequest', error, { url });
throw error;
}
const response = await axios(config);
return response.data;
};
export const financialService = {
/**
* 获取股票基本信息和最新财务摘要
* @param {string} seccode - 股票代码
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getStockInfo: async (seccode) => {
return await apiRequest(`/api/financial/stock-info/${seccode}`);
getStockInfo: async (seccode, options = {}) => {
return await apiRequest(`/api/financial/stock-info/${seccode}`, options);
},
/**
* 获取完整的资产负债表数据
* @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getBalanceSheet: async (seccode, limit = 12) => {
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`);
getBalanceSheet: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`, options);
},
/**
* 获取完整的利润表数据
* @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getIncomeStatement: async (seccode, limit = 12) => {
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`);
getIncomeStatement: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`, options);
},
/**
* 获取完整的现金流量表数据
* @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getCashflow: async (seccode, limit = 12) => {
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`);
getCashflow: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`, options);
},
/**
* 获取完整的财务指标数据
* @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getFinancialMetrics: async (seccode, limit = 12) => {
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`);
getFinancialMetrics: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`, options);
},
/**
* 获取主营业务构成数据
* @param {string} seccode - 股票代码
* @param {number} periods - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getMainBusiness: async (seccode, periods = 4) => {
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`);
getMainBusiness: async (seccode, periods = 4, options = {}) => {
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`, options);
},
/**
* 获取业绩预告和预披露时间
* @param {string} seccode - 股票代码
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getForecast: async (seccode) => {
return await apiRequest(`/api/financial/forecast/${seccode}`);
getForecast: async (seccode, options = {}) => {
return await apiRequest(`/api/financial/forecast/${seccode}`, options);
},
/**
* 获取行业排名数据
* @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getIndustryRank: async (seccode, limit = 4) => {
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`);
getIndustryRank: async (seccode, limit = 4, options = {}) => {
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`, options);
},
/**
* 获取不同报告期的对比数据
* @param {string} seccode - 股票代码
* @param {number} periods - 对比的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/
getPeriodComparison: async (seccode, periods = 8) => {
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`);
getPeriodComparison: async (seccode, periods = 8, options = {}) => {
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`, options);
},
};

View File

@@ -1,53 +1,36 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/marketService.js
/**
* 完整的市场行情数据服务层
* 对应Flask后端的所有市场API接口
*/
import axios from '@utils/axiosConfig';
import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
/**
* 统一的 API 请求函数
* axios 拦截器已自动处理日志记录
*/
const apiRequest = async (url, options = {}) => {
try {
logger.debug('marketService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const { method = 'GET', body, ...rest } = options;
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies以便后端识别登录状态
});
const config = {
method,
url,
...rest,
};
if (!response.ok) {
const errorText = await response.text();
logger.error('marketService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url,
status: response.status,
errorText: errorText.substring(0, 200)
});
throw new Error(`HTTP error! status: ${response.status}`);
// 如果有 body根据方法设置 data 或 params
if (body) {
if (method === 'GET') {
config.params = typeof body === 'string' ? JSON.parse(body) : body;
} else {
config.data = typeof body === 'string' ? JSON.parse(body) : body;
}
const data = await response.json();
logger.debug('marketService', 'API响应', {
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
const response = await axios(config);
return response.data;
};
export const marketService = {

View File

@@ -92,9 +92,18 @@ class SocketService {
// 监听连接错误
this.socket.on('connect_error', (error) => {
this.reconnectAttempts++;
logger.error('socketService', 'connect_error', error, {
attempts: this.reconnectAttempts,
});
// 首次连接失败使用 warn 级别,后续使用 debug 级别减少日志噪音
if (this.reconnectAttempts === 1) {
logger.warn('socketService', `Socket 连接失败,将在后台重试`, {
url: API_BASE_URL,
error: error.message,
});
} else {
logger.debug('socketService', `Socket 重连尝试 #${this.reconnectAttempts}`, {
error: error.message,
});
}
// 使用指数退避策略安排下次重连
this.scheduleReconnection();

View File

@@ -9,6 +9,7 @@ import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import planningReducer from './slices/planningSlice'; // ✅ 投资规划中心状态管理
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
// ⚡ 基础 reducers首屏必需
@@ -19,6 +20,7 @@ const staticReducers = {
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
planning: planningReducer, // ✅ 投资规划中心状态管理
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
};

View File

@@ -0,0 +1,301 @@
/**
* planningSlice - 投资规划中心 Redux Slice
* 管理计划、复盘和日历事件数据
*/
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import type { InvestmentEvent } from '@/types';
import { getApiBase } from '@/utils/apiConfig';
import { logger } from '@/utils/logger';
// ==================== State 类型定义 ====================
interface PlanningState {
/** 所有事件(计划 + 复盘 + 系统事件) */
allEvents: InvestmentEvent[];
/** 加载状态 */
loading: boolean;
/** 错误信息 */
error: string | null;
/** 最后更新时间 */
lastUpdated: number | null;
}
// ==================== 初始状态 ====================
const initialState: PlanningState = {
allEvents: [],
loading: false,
error: null,
lastUpdated: null,
};
// ==================== Async Thunks ====================
/**
* 加载所有事件数据
*/
export const fetchAllEvents = createAsyncThunk(
'planning/fetchAllEvents',
async (_, { rejectWithValue }) => {
try {
const base = getApiBase();
const response = await fetch(`${base}/api/account/calendar/events`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
logger.debug('planningSlice', '数据加载成功', {
count: data.data?.length || 0,
});
return data.data || [];
} else {
throw new Error(data.error || '加载失败');
}
} catch (error) {
logger.error('planningSlice', 'fetchAllEvents', error);
return rejectWithValue(error instanceof Error ? error.message : '加载失败');
}
}
);
/**
* 创建计划/复盘
*/
export const createEvent = createAsyncThunk(
'planning/createEvent',
async (eventData: Partial<InvestmentEvent>, { rejectWithValue, dispatch }) => {
try {
const base = getApiBase();
const response = await fetch(`${base}/api/account/investment-plans`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(eventData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
logger.info('planningSlice', '创建成功', { title: eventData.title });
// 创建成功后重新加载所有数据
dispatch(fetchAllEvents());
return data.data;
} else {
throw new Error(data.error || '创建失败');
}
} catch (error) {
logger.error('planningSlice', 'createEvent', error);
return rejectWithValue(error instanceof Error ? error.message : '创建失败');
}
}
);
/**
* 更新计划/复盘
*/
export const updateEvent = createAsyncThunk(
'planning/updateEvent',
async ({ id, data }: { id: number; data: Partial<InvestmentEvent> }, { rejectWithValue, dispatch }) => {
try {
const base = getApiBase();
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
logger.info('planningSlice', '更新成功', { id });
// 更新成功后重新加载所有数据
dispatch(fetchAllEvents());
return result.data;
} else {
throw new Error(result.error || '更新失败');
}
} catch (error) {
logger.error('planningSlice', 'updateEvent', error);
return rejectWithValue(error instanceof Error ? error.message : '更新失败');
}
}
);
/**
* 删除计划/复盘
*/
export const deleteEvent = createAsyncThunk(
'planning/deleteEvent',
async (id: number, { rejectWithValue, dispatch }) => {
try {
const base = getApiBase();
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
logger.info('planningSlice', '删除成功', { id });
// 删除成功后重新加载所有数据
dispatch(fetchAllEvents());
return id;
} else {
throw new Error(data.error || '删除失败');
}
} catch (error) {
logger.error('planningSlice', 'deleteEvent', error);
return rejectWithValue(error instanceof Error ? error.message : '删除失败');
}
}
);
// ==================== Slice ====================
const planningSlice = createSlice({
name: 'planning',
initialState,
reducers: {
/** 清空数据 */
clearEvents: (state) => {
state.allEvents = [];
state.error = null;
},
/** 直接设置事件(用于乐观更新) */
setEvents: (state, action: PayloadAction<InvestmentEvent[]>) => {
state.allEvents = action.payload;
state.lastUpdated = Date.now();
},
/** 添加单个事件(乐观更新) */
addEvent: (state, action: PayloadAction<InvestmentEvent>) => {
state.allEvents.push(action.payload);
state.lastUpdated = Date.now();
},
/** 乐观添加事件(插入到开头,使用临时 ID */
optimisticAddEvent: (state, action: PayloadAction<InvestmentEvent>) => {
state.allEvents.unshift(action.payload); // 新事件插入开头
state.lastUpdated = Date.now();
},
/** 替换临时事件为真实事件 */
replaceEvent: (state, action: PayloadAction<{ tempId: number; realEvent: InvestmentEvent }>) => {
const { tempId, realEvent } = action.payload;
const index = state.allEvents.findIndex(e => e.id === tempId);
if (index !== -1) {
state.allEvents[index] = realEvent;
}
state.lastUpdated = Date.now();
},
/** 移除事件(用于回滚) */
removeEvent: (state, action: PayloadAction<number>) => {
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
state.lastUpdated = Date.now();
},
/** 乐观更新事件(编辑时使用) */
optimisticUpdateEvent: (state, action: PayloadAction<InvestmentEvent>) => {
const index = state.allEvents.findIndex(e => e.id === action.payload.id);
if (index !== -1) {
state.allEvents[index] = action.payload;
state.lastUpdated = Date.now();
}
},
},
extraReducers: (builder) => {
builder
// fetchAllEvents
.addCase(fetchAllEvents.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchAllEvents.fulfilled, (state, action) => {
state.loading = false;
state.allEvents = action.payload;
state.lastUpdated = Date.now();
})
.addCase(fetchAllEvents.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// createEvent
.addCase(createEvent.pending, (state) => {
state.loading = true;
})
.addCase(createEvent.fulfilled, (state) => {
state.loading = false;
})
.addCase(createEvent.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// updateEvent
.addCase(updateEvent.pending, (state) => {
state.loading = true;
})
.addCase(updateEvent.fulfilled, (state) => {
state.loading = false;
})
.addCase(updateEvent.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
})
// deleteEvent
.addCase(deleteEvent.pending, (state) => {
state.loading = true;
})
.addCase(deleteEvent.fulfilled, (state) => {
state.loading = false;
})
.addCase(deleteEvent.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
// ==================== 导出 ====================
export const {
clearEvents,
setEvents,
addEvent,
optimisticAddEvent,
replaceEvent,
removeEvent,
optimisticUpdateEvent,
} = planningSlice.actions;
export default planningSlice.reducer;
// ==================== Selectors ====================
export const selectAllEvents = (state: { planning: PlanningState }) => state.planning.allEvents;
export const selectPlanningLoading = (state: { planning: PlanningState }) => state.planning.loading;
export const selectPlanningError = (state: { planning: PlanningState }) => state.planning.error;
export const selectPlans = (state: { planning: PlanningState }) =>
state.planning.allEvents.filter(e => e.type === 'plan' && e.source !== 'future');
export const selectReviews = (state: { planning: PlanningState }) =>
state.planning.allEvents.filter(e => e.type === 'review' && e.source !== 'future');

View File

@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ====================
/**
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async () => {
async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist');
try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include'
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
@@ -223,6 +292,132 @@ export const loadAllStocks = createAsyncThunk(
}
);
/**
* 加载自选股实时行情
* 用于统一行情刷新,两个面板共用
*/
export const loadWatchlistQuotes = createAsyncThunk(
'stock/loadWatchlistQuotes',
async () => {
logger.debug('stockSlice', 'loadWatchlistQuotes');
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist/realtime`, {
credentials: 'include',
cache: 'no-store'
});
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
logger.debug('stockSlice', '自选股行情加载成功', { count: data.data.length });
return data.data;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadWatchlistQuotes', error);
return [];
}
}
);
/**
* 加载关注事件列表
* 用于统一关注事件数据源,两个面板共用
*/
export const loadFollowingEvents = createAsyncThunk(
'stock/loadFollowingEvents',
async () => {
logger.debug('stockSlice', 'loadFollowingEvents');
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/events/following`, {
credentials: 'include',
cache: 'no-store'
});
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
// 合并重复的事件(用最新的数据)
const eventMap = new Map();
for (const evt of data.data) {
if (evt && evt.id) {
eventMap.set(evt.id, evt);
}
}
const merged = Array.from(eventMap.values());
// 按创建时间降序排列
if (merged.length > 0 && merged[0].created_at) {
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
} else {
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
}
logger.debug('stockSlice', '关注事件列表加载成功', { count: merged.length });
return merged;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadFollowingEvents', error);
return [];
}
}
);
/**
* 加载用户评论列表
*/
export const loadEventComments = createAsyncThunk(
'stock/loadEventComments',
async () => {
logger.debug('stockSlice', 'loadEventComments');
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/events/posts`, {
credentials: 'include',
cache: 'no-store'
});
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
logger.debug('stockSlice', '用户评论列表加载成功', { count: data.data.length });
return data.data;
}
return [];
} catch (error) {
logger.error('stockSlice', 'loadEventComments', error);
return [];
}
}
);
/**
* 切换关注事件状态(关注/取消关注)
*/
export const toggleFollowEvent = createAsyncThunk(
'stock/toggleFollowEvent',
async ({ eventId, isFollowing }) => {
logger.debug('stockSlice', 'toggleFollowEvent', { eventId, isFollowing });
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/events/${eventId}/follow`, {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || '操作失败');
}
return { eventId, isFollowing };
}
);
/**
* 切换自选股状态
*/
@@ -290,6 +485,15 @@ const stockSlice = createSlice({
// 自选股列表 [{ stock_code, stock_name }]
watchlist: [],
// 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }]
watchlistQuotes: [],
// 关注事件列表 [{ id, title, event_type, ... }]
followingEvents: [],
// 用户评论列表 [{ id, content, event_id, ... }]
eventComments: [],
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
allStocks: [],
@@ -301,6 +505,9 @@ const stockSlice = createSlice({
historicalEvents: false,
chainAnalysis: false,
watchlist: false,
watchlistQuotes: false,
followingEvents: false,
eventComments: false,
allStocks: false
},
@@ -340,6 +547,26 @@ const stockSlice = createSlice({
delete state.historicalEventsCache[eventId];
delete state.chainAnalysisCache[eventId];
delete state.expectationScores[eventId];
},
/**
* 乐观更新:添加自选股(同步)
*/
optimisticAddWatchlist: (state, action) => {
const { stockCode, stockName } = action.payload;
// 避免重复添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
}
},
/**
* 乐观更新:移除自选股(同步)
*/
optimisticRemoveWatchlist: (state, action) => {
const { stockCode } = action.payload;
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
},
extraReducers: (builder) => {
@@ -428,6 +655,18 @@ const stockSlice = createSlice({
state.loading.watchlist = false;
})
// ===== loadWatchlistQuotes =====
.addCase(loadWatchlistQuotes.pending, (state) => {
state.loading.watchlistQuotes = true;
})
.addCase(loadWatchlistQuotes.fulfilled, (state, action) => {
state.watchlistQuotes = action.payload;
state.loading.watchlistQuotes = false;
})
.addCase(loadWatchlistQuotes.rejected, (state) => {
state.loading.watchlistQuotes = false;
})
// ===== loadAllStocks =====
.addCase(loadAllStocks.pending, (state) => {
state.loading.allStocks = true;
@@ -470,9 +709,51 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
})
// ===== loadFollowingEvents =====
.addCase(loadFollowingEvents.pending, (state) => {
state.loading.followingEvents = true;
})
.addCase(loadFollowingEvents.fulfilled, (state, action) => {
state.followingEvents = action.payload;
state.loading.followingEvents = false;
})
.addCase(loadFollowingEvents.rejected, (state) => {
state.loading.followingEvents = false;
})
// ===== loadEventComments =====
.addCase(loadEventComments.pending, (state) => {
state.loading.eventComments = true;
})
.addCase(loadEventComments.fulfilled, (state, action) => {
state.eventComments = action.payload;
state.loading.eventComments = false;
})
.addCase(loadEventComments.rejected, (state) => {
state.loading.eventComments = false;
})
// ===== toggleFollowEvent乐观更新=====
// pending: 立即更新状态
.addCase(toggleFollowEvent.pending, (state, action) => {
const { eventId, isFollowing } = action.meta.arg;
if (isFollowing) {
// 当前已关注,取消关注 → 移除
state.followingEvents = state.followingEvents.filter(evt => evt.id !== eventId);
}
// 添加关注的情况需要事件完整数据,不在这里处理
})
// rejected: 回滚状态(仅取消关注需要回滚)
.addCase(toggleFollowEvent.rejected, (state, action) => {
// 取消关注失败时,需要刷新列表恢复数据
// 由于没有原始事件数据,这里只能触发重新加载
logger.warn('stockSlice', 'toggleFollowEvent rejected, 需要重新加载关注事件列表');
});
}
});
@@ -481,7 +762,9 @@ export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;

View File

@@ -0,0 +1,9 @@
/* 全局隐藏滚动条(保持滚动功能) */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}

View File

@@ -87,3 +87,55 @@ select::-webkit-scrollbar-thumb {
select::-webkit-scrollbar-thumb:hover {
background: #FFC107;
}
/**
* Ant Design AutoComplete 下拉框样式 (FUI 主题)
*/
.fui-autocomplete-dropdown {
background-color: #1a1a2e !important;
border: 1px solid rgba(212, 175, 55, 0.3) !important;
border-radius: 10px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important;
}
.fui-autocomplete-dropdown .ant-select-item {
color: #ffffff !important;
padding: 10px 12px !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.1);
}
.fui-autocomplete-dropdown .ant-select-item:last-child {
border-bottom: none;
}
.fui-autocomplete-dropdown .ant-select-item-option-active,
.fui-autocomplete-dropdown .ant-select-item:hover {
background-color: rgba(212, 175, 55, 0.15) !important;
}
.fui-autocomplete-dropdown .ant-select-item-option-selected {
background-color: rgba(212, 175, 55, 0.25) !important;
}
.fui-autocomplete-dropdown .ant-select-item-empty {
color: rgba(255, 255, 255, 0.5) !important;
}
/* AutoComplete 下拉框滚动条 */
.fui-autocomplete-dropdown::-webkit-scrollbar {
width: 6px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.4);
border-radius: 3px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(212, 175, 55, 0.6);
}

361
src/types/center.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* Center个人中心模块类型定义
*
* 包含自选股、实时行情、关注事件等类型
*/
import type { NavigateFunction } from 'react-router-dom';
// ============================================================
// Dashboard Events Hook 类型定义
// ============================================================
/**
* useDashboardEvents Hook 配置选项
*/
export interface DashboardEventsOptions {
/** 页面类型 */
pageType?: 'center' | 'profile' | 'settings';
/** 路由导航函数 */
navigate?: NavigateFunction;
}
/**
* useDashboardEvents Hook 返回值
*/
export interface DashboardEventsResult {
/** 追踪功能卡片点击 */
trackFunctionCardClicked: (cardName: string, cardData?: { count?: number }) => void;
/** 追踪自选股列表查看 */
trackWatchlistViewed: (stockCount?: number, hasRealtime?: boolean) => void;
/** 追踪自选股点击 */
trackWatchlistStockClicked: (stock: { code: string; name?: string }, position?: number) => void;
/** 追踪自选股添加 */
trackWatchlistStockAdded: (stock: { code: string; name?: string }, source?: string) => void;
/** 追踪自选股移除 */
trackWatchlistStockRemoved: (stock: { code: string; name?: string }) => void;
/** 追踪关注事件列表查看 */
trackFollowingEventsViewed: (eventCount?: number) => void;
/** 追踪关注事件点击 */
trackFollowingEventClicked: (event: { id: number; title?: string }, position?: number) => void;
/** 追踪评论列表查看 */
trackCommentsViewed: (commentCount?: number) => void;
/** 追踪订阅信息查看 */
trackSubscriptionViewed: (subscription?: { plan?: string; status?: string }) => void;
/** 追踪升级按钮点击 */
trackUpgradePlanClicked: (currentPlan?: string, targetPlan?: string, source?: string) => void;
/** 追踪个人资料更新 */
trackProfileUpdated: (updatedFields?: string[]) => void;
/** 追踪设置更改 */
trackSettingChanged: (settingName: string, oldValue: unknown, newValue: unknown) => void;
}
/**
* 自选股项目
* 来自 /api/account/watchlist 接口
*/
export interface WatchlistItem {
/** 股票代码(如 '600000.SH' */
stock_code: string;
/** 股票名称 */
stock_name: string;
/** 当前价格 */
current_price?: number;
/** 涨跌幅(百分比) */
change_percent?: number;
/** 添加时间 */
created_at?: string;
/** 备注 */
note?: string;
}
/**
* 实时行情数据
* 来自 /api/account/watchlist/realtime 接口
*/
export interface RealtimeQuote {
/** 股票代码 */
stock_code: string;
/** 当前价格 */
current_price: number;
/** 涨跌幅(百分比) */
change_percent: number;
/** 涨跌额 */
change_amount?: number;
/** 成交量 */
volume?: number;
/** 成交额 */
amount?: number;
/** 最高价 */
high?: number;
/** 最低价 */
low?: number;
/** 开盘价 */
open?: number;
/** 昨收价 */
pre_close?: number;
/** 更新时间戳 */
timestamp?: number;
}
/**
* 实时行情映射表
* key 为股票代码value 为行情数据
*/
export type RealtimeQuotesMap = Record<string, RealtimeQuote>;
/**
* 关注的事件
* 来自 /api/account/events/following 接口
*/
export interface FollowingEvent {
/** 事件 ID */
id: number;
/** 事件标题 */
title: string;
/** 关注人数 */
follower_count?: number;
/** 相关股票平均涨跌幅(百分比) */
related_avg_chg?: number;
/** 事件类型 */
event_type?: string;
/** 发生日期 */
event_date?: string;
/** 事件描述 */
description?: string;
/** 相关股票列表 */
related_stocks?: Array<{
code: string;
name: string;
change_percent?: number;
}>;
/** 创建时间 */
created_at?: string;
}
/**
* 用户评论记录
* 来自 /api/account/events/posts 接口
*/
export interface EventComment {
/** 评论 ID */
id: number;
/** 评论内容 */
content: string;
/** 关联事件 ID */
event_id: number;
/** 关联事件标题 */
event_title?: string;
/** 点赞数 */
like_count?: number;
/** 回复数 */
reply_count?: number;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at?: string;
}
// ============================================================
// 组件 Props 类型定义
// ============================================================
/**
* WatchSidebar 组件 Props
*/
export interface WatchSidebarProps {
/** 自选股列表 */
watchlist: WatchlistItem[];
/** 实时行情数据(按股票代码索引) */
realtimeQuotes: RealtimeQuotesMap;
/** 关注的事件列表 */
followingEvents: FollowingEvent[];
/** 用户评论列表 */
eventComments?: EventComment[];
/** 点击股票回调 */
onStockClick?: (stock: WatchlistItem) => void;
/** 点击事件回调 */
onEventClick?: (event: FollowingEvent) => void;
/** 点击评论回调 */
onCommentClick?: (comment: EventComment) => void;
/** 添加股票回调 */
onAddStock?: () => void;
/** 添加事件回调 */
onAddEvent?: () => void;
}
/**
* WatchlistPanel 组件 Props
*/
export interface WatchlistPanelProps {
/** 自选股列表 */
watchlist: WatchlistItem[];
/** 实时行情数据 */
realtimeQuotes: RealtimeQuotesMap;
/** 点击股票回调 */
onStockClick?: (stock: WatchlistItem) => void;
/** 添加股票回调 */
onAddStock?: () => void;
}
/**
* FollowingEventsPanel 组件 Props
*/
export interface FollowingEventsPanelProps {
/** 事件列表 */
events: FollowingEvent[];
/** 用户评论列表 */
eventComments?: EventComment[];
/** 点击事件回调 */
onEventClick?: (event: FollowingEvent) => void;
/** 点击评论回调 */
onCommentClick?: (comment: EventComment) => void;
/** 添加事件回调 */
onAddEvent?: () => void;
}
// ============================================================
// Hooks 返回值类型定义
// ============================================================
/**
* useCenterColors Hook 返回值
* 封装 Center 模块的所有颜色变量
*/
export interface CenterColors {
/** 主要文本颜色 */
textColor: string;
/** 边框颜色 */
borderColor: string;
/** 背景颜色 */
bgColor: string;
/** 悬停背景色 */
hoverBg: string;
/** 次要文本颜色 */
secondaryText: string;
/** 卡片背景色 */
cardBg: string;
/** 区块背景色 */
sectionBg: string;
}
/**
* useCenterData Hook 返回值
* 封装 Center 页面的数据加载逻辑
*/
export interface UseCenterDataResult {
/** 自选股列表 */
watchlist: WatchlistItem[];
/** 实时行情数据 */
realtimeQuotes: RealtimeQuotesMap;
/** 关注的事件列表 */
followingEvents: FollowingEvent[];
/** 用户评论列表 */
eventComments: EventComment[];
/** 加载状态 */
loading: boolean;
/** 行情加载状态 */
quotesLoading: boolean;
/** 刷新数据 */
refresh: () => Promise<void>;
/** 刷新实时行情 */
refreshQuotes: () => Promise<void>;
}
// ============================================================
// API 响应类型定义
// ============================================================
/**
* 自选股列表 API 响应
*/
export interface WatchlistApiResponse {
success: boolean;
data: WatchlistItem[];
message?: string;
}
/**
* 实时行情 API 响应
*/
export interface RealtimeQuotesApiResponse {
success: boolean;
data: RealtimeQuote[];
message?: string;
}
/**
* 关注事件 API 响应
*/
export interface FollowingEventsApiResponse {
success: boolean;
data: FollowingEvent[];
message?: string;
}
/**
* 用户评论 API 响应
*/
export interface EventCommentsApiResponse {
success: boolean;
data: EventComment[];
message?: string;
}

View File

@@ -63,3 +63,23 @@ export type {
PlanFormData,
PlanningContextValue,
} from './investment';
// Center个人中心相关类型
export type {
DashboardEventsOptions,
DashboardEventsResult,
WatchlistItem,
RealtimeQuote,
RealtimeQuotesMap,
FollowingEvent,
EventComment,
WatchSidebarProps,
WatchlistPanelProps,
FollowingEventsPanelProps,
CenterColors,
UseCenterDataResult,
WatchlistApiResponse,
RealtimeQuotesApiResponse,
FollowingEventsApiResponse,
EventCommentsApiResponse,
} from './center';

View File

@@ -52,6 +52,11 @@ axios.interceptors.response.use(
return response;
},
(error) => {
// 忽略取消请求的错误(组件卸载时正常行为)
if (error.name === 'CanceledError' || axios.isCancel(error)) {
return Promise.reject(error);
}
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
const url = error.config?.url || 'UNKNOWN';
const requestData = error.config?.data || error.config?.params || null;

View File

@@ -2,6 +2,7 @@
// 性能监控工具 - 统计白屏时间和性能指标
import { logger } from './logger';
import { reportPerformanceMetrics } from '../lib/posthog';
/**
* 性能指标接口
@@ -208,6 +209,9 @@ class PerformanceMonitor {
// 性能分析建议
this.analyzePerformance();
// 上报性能指标到 PostHog
reportPerformanceMetrics(this.metrics);
return this.metrics;
}

View File

@@ -103,3 +103,71 @@ export const PriceArrow = ({ value }) => {
return <Icon color={color} boxSize="16px" />;
};
// ==================== 货币/数值格式化 ====================
/**
* 格式化货币金额(自动选择单位:亿元/万元/元)
* @param {number|null|undefined} value - 金额(单位:元)
* @returns {string} 格式化后的金额字符串
*/
export const formatCurrency = (value) => {
if (value === null || value === undefined) return '-';
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(2) + '元';
};
/**
* 格式化业务营收(支持指定单位)
* @param {number|null|undefined} value - 营收金额
* @param {string} [unit] - 原始单位(元/万元/亿元)
* @returns {string} 格式化后的营收字符串
*/
export const formatBusinessRevenue = (value, unit) => {
if (value === null || value === undefined) return '-';
if (unit) {
if (unit === '元') {
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(0) + '元';
} else if (unit === '万元') {
const absValue = Math.abs(value);
if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '亿元';
}
return value.toFixed(2) + '万元';
} else if (unit === '亿元') {
return value.toFixed(2) + '亿元';
} else {
return value.toFixed(2) + unit;
}
}
// 无单位时,假设为元
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(2) + '元';
};
/**
* 格式化百分比
* @param {number|null|undefined} value - 百分比值
* @param {number} [decimals=2] - 小数位数
* @returns {string} 格式化后的百分比字符串
*/
export const formatPercentage = (value, decimals = 2) => {
if (value === null || value === undefined) return '-';
return value.toFixed(decimals) + '%';
};

View File

@@ -0,0 +1,238 @@
/**
* CalendarPanel - 投资日历面板组件
* 使用 Ant Design Calendar 展示投资计划、复盘等事件
*
* 聚合展示模式:每个日期格子显示各类型事件的汇总信息
*/
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
import {
Box,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Spinner,
Center,
VStack,
} from '@chakra-ui/react';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import { useAppSelector } from '@/store/hooks';
import { selectAllEvents } from '@/store/slices/planningSlice';
import { usePlanningData } from './PlanningContext';
import { EventDetailModal } from './EventDetailModal';
import type { InvestmentEvent } from '@/types';
// 使用新的公共日历组件
import {
BaseCalendar,
CalendarEventBlock,
type CellRenderInfo,
type CalendarEvent,
CALENDAR_COLORS,
} from '@components/Calendar';
// 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
dayjs.locale('zh-cn');
/**
* 事件聚合信息(用于日历格子显示)
*/
interface EventSummary {
plans: InvestmentEvent[];
reviews: InvestmentEvent[];
systems: InvestmentEvent[];
}
/**
* CalendarPanel 组件
* 日历视图面板,显示所有投资事件
*/
export const CalendarPanel: React.FC = () => {
// 从 Redux 获取数据(确保与列表视图同步)
const allEvents = useAppSelector(selectAllEvents);
// UI 相关状态仍从 Context 获取
const {
borderColor,
secondaryText,
setViewMode,
setListTab,
} = usePlanningData();
// 弹窗状态(统一使用 useState
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
const [initialFilter, setInitialFilter] = useState<'all' | 'plan' | 'review' | 'system'>('all');
// 按日期分组事件(用于聚合展示)
const eventsByDate = useMemo(() => {
const map: Record<string, EventSummary> = {};
allEvents.forEach(event => {
const dateStr = event.event_date;
if (!map[dateStr]) {
map[dateStr] = { plans: [], reviews: [], systems: [] };
}
if (event.source === 'future') {
map[dateStr].systems.push(event);
} else if (event.type === 'plan') {
map[dateStr].plans.push(event);
} else if (event.type === 'review') {
map[dateStr].reviews.push(event);
}
});
return map;
}, [allEvents]);
// 将事件摘要转换为 CalendarEvent 格式
const getCalendarEvents = useCallback((dateStr: string): CalendarEvent[] => {
const summary = eventsByDate[dateStr];
if (!summary) return [];
const events: CalendarEvent[] = [];
// 系统事件
if (summary.systems.length > 0) {
events.push({
id: `${dateStr}-system`,
type: 'system',
title: summary.systems[0]?.title || '',
date: dateStr,
count: summary.systems.length,
});
}
// 计划
if (summary.plans.length > 0) {
events.push({
id: `${dateStr}-plan`,
type: 'plan',
title: summary.plans[0]?.title || '',
date: dateStr,
count: summary.plans.length,
});
}
// 复盘
if (summary.reviews.length > 0) {
events.push({
id: `${dateStr}-review`,
type: 'review',
title: summary.reviews[0]?.title || '',
date: dateStr,
count: summary.reviews.length,
});
}
return events;
}, [eventsByDate]);
// 处理日期选择(点击日期空白区域)
const handleDateSelect = useCallback((date: Dayjs): void => {
setSelectedDate(date);
const dayEvents = allEvents.filter(event =>
dayjs(event.event_date).isSame(date, 'day')
);
setSelectedDateEvents(dayEvents);
setInitialFilter('all'); // 点击日期时显示全部
setIsDetailModalOpen(true);
}, [allEvents]);
// 处理事件点击(点击具体事件类型)
const handleEventClick = useCallback((event: CalendarEvent): void => {
const date = dayjs(event.date);
setSelectedDate(date);
const dayEvents = allEvents.filter(e =>
dayjs(e.event_date).isSame(date, 'day')
);
setSelectedDateEvents(dayEvents);
setInitialFilter(event.type as 'plan' | 'review' | 'system'); // 定位到对应 Tab
setIsDetailModalOpen(true);
}, [allEvents]);
// 自定义日期格子内容渲染
const renderCellContent = useCallback((date: Dayjs, _info: CellRenderInfo) => {
const dateStr = date.format('YYYY-MM-DD');
const events = getCalendarEvents(dateStr);
if (events.length === 0) return null;
return (
<VStack spacing={0} align="stretch" w="100%" mt={1}>
<CalendarEventBlock
events={events}
maxDisplay={3}
compact
onEventClick={handleEventClick}
/>
</VStack>
);
}, [getCalendarEvents, handleEventClick]);
return (
<Box>
{/* 日历容器 - 使用 BaseCalendar */}
<Box height={{ base: '420px', md: '600px' }}>
<BaseCalendar
onSelect={handleDateSelect}
cellRender={renderCellContent}
height="100%"
showToolbar={true}
/>
</Box>
{/* 查看事件详情 Modal */}
<EventDetailModal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
selectedDate={selectedDate}
events={selectedDateEvents}
borderColor={borderColor}
secondaryText={secondaryText}
initialFilter={initialFilter}
onNavigateToPlan={() => {
setViewMode('list');
setListTab(0);
}}
onNavigateToReview={() => {
setViewMode('list');
setListTab(1);
}}
onOpenInvestmentCalendar={() => {
setIsInvestmentCalendarOpen(true);
}}
/>
{/* 投资日历 Modal */}
{isInvestmentCalendarOpen && (
<Modal
isOpen={isInvestmentCalendarOpen}
onClose={() => setIsInvestmentCalendarOpen(false)}
size={{ base: 'full', md: '6xl' }}
>
<ModalOverlay />
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}></ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
<InvestmentCalendar />
</Suspense>
</ModalBody>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -0,0 +1,142 @@
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
// ==================== 变量定义 ====================
// 与 GlassCard transparent 变体保持一致
@color-bg-deep: rgba(15, 15, 26, 0.95);
@color-bg-primary: #0F0F1A;
@color-bg-elevated: #1A1A2E;
@color-gold-400: #D4AF37;
@color-gold-500: #B8960C;
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
@color-line-subtle: rgba(212, 175, 55, 0.1);
@color-line-default: rgba(212, 175, 55, 0.2);
@color-text-primary: rgba(255, 255, 255, 0.95);
@color-text-secondary: rgba(255, 255, 255, 0.6);
// ==================== 全局 Modal 遮罩层样式 ====================
.event-detail-modal-root {
.ant-modal-mask {
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(4px);
}
}
// ==================== 主样式 ====================
.event-detail-modal {
// Modal 整体
.ant-modal-content {
background: @color-bg-deep !important;
border: 1px solid @color-line-default;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 24px rgba(212, 175, 55, 0.08);
}
// Modal 头部
.ant-modal-header {
background: transparent !important;
border-bottom: 1px solid @color-line-subtle;
padding: 16px 24px;
}
// Modal 标题
.ant-modal-title {
font-size: 18px;
font-weight: 700;
background: @color-gold-gradient;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// 关闭按钮
.ant-modal-close {
color: rgba(255, 255, 255, 0.9) !important;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
.ant-modal-close-x {
font-size: 16px;
line-height: 1;
}
&:hover {
color: @color-gold-400 !important;
background: rgba(212, 175, 55, 0.2);
}
}
// Modal 内容区域
.ant-modal-body {
padding: 16px 24px 24px;
background: transparent;
}
}
// ==================== 响应式适配 ====================
@media (max-width: 768px) {
.event-detail-modal {
.ant-modal-content {
border-radius: 0;
min-height: 100vh;
}
.ant-modal-header {
padding: 12px 16px;
}
.ant-modal-title {
font-size: 16px;
}
.ant-modal-body {
padding: 12px 16px 20px;
}
}
.event-detail-modal-root {
.ant-modal {
max-width: 100vw !important;
margin: 0 !important;
top: 0 !important;
padding: 0 !important;
}
.ant-modal-centered .ant-modal {
top: 0 !important;
}
}
}
// ==================== 滚动条样式(备用,已在组件内定义) ====================
.event-detail-modal {
// 自定义滚动条
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.3);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: rgba(212, 175, 55, 0.5);
}
}
}

View File

@@ -0,0 +1,315 @@
/**
* EventDetailModal - 事件详情弹窗组件
* 用于展示某一天的所有投资事件
*
* 功能:
* - Tab 筛选(全部/计划/复盘/系统)
* - 两列网格布局
* - 响应式宽度
*/
import React, { useState, useMemo } from 'react';
import { Modal } from 'antd';
import {
Box,
Grid,
HStack,
Button,
Text,
Badge,
Icon,
useBreakpointValue,
} from '@chakra-ui/react';
import { Target, Heart, Calendar, LayoutGrid } from 'lucide-react';
import type { Dayjs } from 'dayjs';
import { FUIEventCard } from './FUIEventCard';
import { EventEmptyState } from './EventEmptyState';
import type { InvestmentEvent } from '@/types';
import './EventDetailModal.less';
/**
* 筛选类型
*/
type FilterType = 'all' | 'plan' | 'review' | 'system';
/**
* 筛选器配置
*/
interface FilterOption {
key: FilterType;
label: string;
icon?: React.ElementType;
color: string;
}
const FILTER_OPTIONS: FilterOption[] = [
{
key: 'all',
label: '全部',
icon: LayoutGrid,
color: '#9B59B6', // 紫罗兰色,汇总概念用独特色
},
{
key: 'plan',
label: '计划',
icon: Target,
color: '#D4AF37',
},
{
key: 'review',
label: '复盘',
icon: Heart,
color: '#10B981',
},
{
key: 'system',
label: '系统',
icon: Calendar,
color: '#3B82F6',
},
];
/**
* 根据 hex 颜色生成 rgba 颜色
*/
const hexToRgba = (hex: string, alpha: number): string => {
// 处理 rgba 格式
if (hex.startsWith('rgba')) {
return hex;
}
// 处理 hex 格式
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return hex;
};
/**
* EventDetailModal Props
*/
export interface EventDetailModalProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 选中的日期 */
selectedDate: Dayjs | null;
/** 选中日期的事件列表 */
events: InvestmentEvent[];
/** 边框颜色 */
borderColor?: string;
/** 次要文字颜色 */
secondaryText?: string;
/** 导航到计划列表 */
onNavigateToPlan?: () => void;
/** 导航到复盘列表 */
onNavigateToReview?: () => void;
/** 打开投资日历 */
onOpenInvestmentCalendar?: () => void;
/** 初始筛选类型(点击事件时定位到对应 Tab */
initialFilter?: FilterType;
}
/**
* 获取各类型事件数量
*/
const getEventCounts = (events: InvestmentEvent[]) => {
const counts = { all: events.length, plan: 0, review: 0, system: 0 };
events.forEach(event => {
if (event.source === 'future') {
counts.system++;
} else if (event.type === 'plan') {
counts.plan++;
} else if (event.type === 'review') {
counts.review++;
}
});
return counts;
};
/**
* EventDetailModal 组件
*/
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
isOpen,
onClose,
selectedDate,
events,
onNavigateToPlan,
onNavigateToReview,
onOpenInvestmentCalendar,
initialFilter,
}) => {
// 筛选状态
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
// 响应式弹窗宽度
const modalWidth = useBreakpointValue({ base: '100%', md: 600, lg: 800 }) || 600;
// 响应式网格列数
const gridColumns = useBreakpointValue({ base: 1, md: 2 }) || 1;
// 各类型事件数量
const eventCounts = useMemo(() => getEventCounts(events), [events]);
// 筛选后的事件
const filteredEvents = useMemo(() => {
if (activeFilter === 'all') return events;
if (activeFilter === 'system') return events.filter(e => e.source === 'future');
return events.filter(e => e.type === activeFilter && e.source !== 'future');
}, [events, activeFilter]);
// 弹窗打开时设置筛选(使用 initialFilter 或默认 'all'
React.useEffect(() => {
if (isOpen) {
setActiveFilter(initialFilter || 'all');
}
}, [isOpen, initialFilter]);
return (
<Modal
open={isOpen}
onCancel={onClose}
title={
<HStack spacing={2}>
<Text color="rgba(255, 255, 255, 0.85)" fontWeight="500">
{selectedDate?.format('YYYY年MM月DD日') || ''}
</Text>
<Badge
bg="rgba(212, 175, 55, 0.15)"
color="#D4AF37"
fontSize="xs"
px={2}
borderRadius="full"
>
{events.length}
</Badge>
</HStack>
}
footer={null}
width={modalWidth}
maskClosable={false}
keyboard={true}
centered
className="event-detail-modal"
rootClassName="event-detail-modal-root"
styles={{
content: {
background: 'rgba(15, 15, 26, 0.95)',
border: '1px solid rgba(212, 175, 55, 0.2)',
},
header: {
background: 'transparent',
},
body: { paddingTop: 8, paddingBottom: 24 },
}}
>
{events.length === 0 ? (
<EventEmptyState
onNavigateToPlan={() => {
onClose();
onNavigateToPlan?.();
}}
onNavigateToReview={() => {
onClose();
onNavigateToReview?.();
}}
onOpenInvestmentCalendar={() => {
onClose();
onOpenInvestmentCalendar?.();
}}
/>
) : (
<Box>
{/* Tab 筛选器 */}
<HStack spacing={2} mb={4} flexWrap="wrap">
{FILTER_OPTIONS.map(option => {
const count = eventCounts[option.key];
const isActive = activeFilter === option.key;
return (
<Button
key={option.key}
size="sm"
variant={isActive ? 'solid' : 'ghost'}
bg={isActive ? hexToRgba(option.color, 0.2) : 'rgba(168, 180, 192, 0.05)'}
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
border="1px solid"
borderColor={isActive ? hexToRgba(option.color, 0.5) : 'rgba(168, 180, 192, 0.15)'}
boxShadow={isActive ? `0 0 12px ${hexToRgba(option.color, 0.3)}` : 'none'}
_hover={{
bg: hexToRgba(option.color, 0.12),
borderColor: hexToRgba(option.color, 0.3),
color: hexToRgba(option.color, 0.9),
}}
onClick={() => setActiveFilter(option.key)}
leftIcon={option.icon ? <Icon as={option.icon} boxSize={3.5} /> : undefined}
rightIcon={
count > 0 ? (
<Badge
bg={isActive ? hexToRgba(option.color, 0.3) : 'rgba(168, 180, 192, 0.1)'}
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
fontSize="10px"
px={1.5}
borderRadius="full"
ml={1}
>
{count}
</Badge>
) : undefined
}
>
{option.label}
</Button>
);
})}
</HStack>
{/* 事件网格 */}
<Box
maxH="60vh"
overflowY="auto"
pr={2}
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255, 255, 255, 0.05)' },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(212, 175, 55, 0.3)',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(212, 175, 55, 0.5)' },
}}
>
{filteredEvents.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="rgba(255, 255, 255, 0.5)" fontSize="sm">
</Text>
</Box>
) : (
<Grid
templateColumns={`repeat(${gridColumns}, 1fr)`}
gap={4}
>
{filteredEvents.map((event, idx) => (
<FUIEventCard
key={event.id || idx}
event={event}
variant="modal"
/>
))}
</Grid>
)}
</Box>
</Box>
)}
</Modal>
);
};
export default EventDetailModal;

View File

@@ -0,0 +1,576 @@
/* EventFormModal.less - 投资计划/复盘弹窗黑金主题样式 */
// ==================== 变量定义 ====================
@mobile-breakpoint: 768px;
@modal-border-radius-mobile: 16px;
@modal-border-radius-desktop: 12px;
// 间距
@spacing-xs: 4px;
@spacing-sm: 8px;
@spacing-md: 12px;
@spacing-lg: 16px;
@spacing-xl: 20px;
@spacing-xxl: 24px;
// 字体大小
@font-size-xs: 12px;
@font-size-sm: 14px;
@font-size-md: 16px;
// 黑金主题色
@color-bg-deep: #0A0A14;
@color-bg-primary: #0F0F1A;
@color-bg-elevated: #1A1A2E;
@color-bg-surface: #252540;
@color-bg-input: rgba(26, 26, 46, 0.8);
@color-gold-400: #D4AF37;
@color-gold-500: #B8960C;
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
@color-line-subtle: rgba(212, 175, 55, 0.1);
@color-line-default: rgba(212, 175, 55, 0.2);
@color-line-emphasis: rgba(212, 175, 55, 0.4);
@color-text-primary: rgba(255, 255, 255, 0.95);
@color-text-secondary: rgba(255, 255, 255, 0.6);
@color-text-muted: rgba(255, 255, 255, 0.4);
@color-error: #EF4444;
// ==================== 全局 Modal 遮罩层样式 ====================
// 使用 rootClassName="event-form-modal-root" 来控制 mask 样式
.event-form-modal-root {
.ant-modal-mask {
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(4px);
}
.ant-modal-wrap {
// 确保 wrap 层也有正确的样式
}
}
// ==================== 主样式 ====================
.event-form-modal {
// Modal 整体
.ant-modal-content {
background: linear-gradient(135deg, @color-bg-elevated 0%, @color-bg-primary 100%);
border: 1px solid @color-line-default;
border-radius: @modal-border-radius-desktop;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1);
backdrop-filter: blur(16px);
}
// Modal 头部
.ant-modal-header {
background: transparent;
border-bottom: 1px solid @color-line-subtle;
padding: @spacing-lg @spacing-xxl;
}
// Modal 标题
.ant-modal-title {
font-size: 20px;
font-weight: 700;
background: @color-gold-gradient;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// 关闭按钮
.ant-modal-close {
color: @color-text-secondary;
transition: all 0.3s ease;
&:hover {
color: @color-gold-400;
background: rgba(212, 175, 55, 0.1);
}
}
.ant-modal-body {
padding: @spacing-xxl;
padding-top: 24px;
}
// Modal 底部
.ant-modal-footer {
background: transparent;
border-top: 1px solid @color-line-subtle;
padding: @spacing-lg @spacing-xxl;
}
.ant-form-item {
margin-bottom: @spacing-xl;
}
// 表单标签
.ant-form-item-label {
text-align: left !important;
> label {
font-weight: 600 !important;
color: @color-text-primary !important;
}
}
// 输入框通用样式(支持 Ant Design v5 的 outlined 类名)
.ant-input,
.ant-input-outlined,
.ant-picker,
.ant-picker-outlined,
.ant-select-selector,
.ant-select-outlined .ant-select-selector {
background: @color-bg-input !important;
border: 1px solid @color-line-default !important;
color: @color-text-primary !important;
transition: all 0.3s ease;
&:hover {
border-color: @color-line-emphasis !important;
background: @color-bg-input !important;
}
&:focus,
&:focus-within,
&.ant-input-focused,
&.ant-picker-focused,
&.ant-select-focused {
border-color: @color-gold-400 !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
background: @color-bg-input !important;
}
&::placeholder {
color: @color-text-muted !important;
}
}
// 输入框 focus 状态增强
.ant-input:focus,
.ant-input-outlined:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused {
border-color: @color-gold-400 !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
}
// 输入框 wrapper
.ant-input-affix-wrapper {
background: @color-bg-input !important;
border-color: @color-line-default !important;
.ant-input {
background: transparent !important;
}
.ant-input-suffix {
color: @color-text-muted;
}
}
// 输入框 placeholder
.ant-input::placeholder,
.ant-picker-input input::placeholder,
.ant-select-selection-placeholder {
color: @color-text-muted !important;
}
// 文本域
.ant-input-textarea,
.ant-input-textarea-show-count {
textarea,
.ant-input {
background: @color-bg-input !important;
border: 1px solid @color-line-default !important;
color: @color-text-primary !important;
&:hover {
border-color: @color-line-emphasis !important;
background: @color-bg-input !important;
}
&:focus {
border-color: @color-gold-400 !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
background: @color-bg-input !important;
}
&::placeholder {
color: @color-text-muted !important;
}
}
}
// 文本域 outlined 变体
.ant-input-textarea-outlined,
textarea.ant-input-outlined {
background: @color-bg-input !important;
border: 1px solid @color-line-default !important;
color: @color-text-primary !important;
&:hover {
border-color: @color-line-emphasis !important;
}
&:focus {
border-color: @color-gold-400 !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
}
}
// 字符计数样式
.ant-input-textarea-show-count::after {
font-size: @font-size-xs;
color: @color-text-muted;
}
// 日期选择器
.ant-picker {
width: 100%;
.ant-picker-input > input {
color: @color-text-primary !important;
}
.ant-picker-suffix,
.ant-picker-clear {
color: @color-text-secondary;
}
}
// Select 选择器
.ant-select {
.ant-select-selector {
min-height: 38px;
background: @color-bg-input !important;
border: 1px solid @color-line-default !important;
}
&.ant-select-focused .ant-select-selector,
&:hover .ant-select-selector {
border-color: @color-line-emphasis !important;
}
&.ant-select-focused .ant-select-selector {
border-color: @color-gold-400 !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
}
.ant-select-selection-item {
color: @color-text-primary !important;
}
.ant-select-selection-search-input {
color: @color-text-primary !important;
}
.ant-select-arrow {
color: @color-text-secondary;
}
.ant-select-clear {
background: @color-bg-elevated;
color: @color-text-secondary;
&:hover {
color: @color-gold-400;
}
}
}
// Select 多选模式下的标签
.ant-select-multiple .ant-select-selection-item {
background: rgba(212, 175, 55, 0.15) !important;
border: 1px solid rgba(212, 175, 55, 0.3) !important;
color: @color-gold-400 !important;
.ant-select-selection-item-remove {
color: @color-gold-400;
&:hover {
color: @color-text-primary;
}
}
}
// 股票标签样式
.ant-tag {
margin: 2px;
border-radius: @spacing-xs;
background: rgba(212, 175, 55, 0.15) !important;
border: 1px solid rgba(212, 175, 55, 0.3) !important;
color: @color-gold-400 !important;
.ant-tag-close-icon {
color: @color-gold-400;
&:hover {
color: @color-text-primary;
}
}
}
// 模板按钮组
.template-buttons {
.ant-btn {
font-size: @font-size-xs;
background: rgba(212, 175, 55, 0.1);
border: 1px solid @color-line-default;
color: @color-text-secondary;
transition: all 0.3s ease;
&:hover {
background: rgba(212, 175, 55, 0.2);
border-color: @color-line-emphasis;
color: @color-gold-400;
}
}
}
// 底部操作栏布局
.modal-footer {
display: flex;
justify-content: flex-end;
.ant-btn-primary {
background: linear-gradient(135deg, @color-gold-400 0%, @color-gold-500 100%);
border: none;
color: @color-bg-deep;
font-weight: 600;
height: 40px;
padding: 0 24px;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, lighten(@color-gold-400, 5%) 0%, lighten(@color-gold-500, 5%) 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.3);
}
&:active {
transform: translateY(0);
}
&:disabled {
background: rgba(212, 175, 55, 0.3);
color: @color-text-muted;
}
}
}
// 加载状态
.ant-btn-loading {
opacity: 0.8;
}
// 错误状态
.ant-form-item-has-error {
.ant-input,
.ant-picker,
.ant-select-selector {
border-color: @color-error !important;
animation: shake 0.3s ease-in-out;
}
}
.ant-form-item-explain-error {
color: @color-error;
}
}
// Select 下拉菜单(挂载在 body 上,需要全局样式)
.ant-select-dropdown {
background: @color-bg-elevated !important;
border: 1px solid @color-line-default;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
.ant-select-item {
color: @color-text-primary;
&:hover {
background: rgba(212, 175, 55, 0.1);
}
&.ant-select-item-option-selected {
background: rgba(212, 175, 55, 0.2);
color: @color-gold-400;
}
&.ant-select-item-option-active {
background: rgba(212, 175, 55, 0.15);
}
}
.ant-select-item-empty {
color: @color-text-muted;
}
// 分割线
.ant-divider {
border-color: @color-line-subtle;
}
// 提示文字
span[style*="color: #999"] {
color: @color-text-muted !important;
}
}
// 日期选择器下拉面板
.ant-picker-dropdown {
.ant-picker-panel-container {
background: @color-bg-elevated;
border: 1px solid @color-line-default;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.ant-picker-header {
color: @color-text-primary;
border-bottom: 1px solid @color-line-subtle;
button {
color: @color-text-secondary;
&:hover {
color: @color-gold-400;
}
}
}
.ant-picker-content th {
color: @color-text-muted;
}
.ant-picker-cell {
color: @color-text-secondary;
&:hover .ant-picker-cell-inner {
background: rgba(212, 175, 55, 0.1);
}
&.ant-picker-cell-in-view {
color: @color-text-primary;
}
&.ant-picker-cell-selected .ant-picker-cell-inner {
background: @color-gold-400;
color: @color-bg-deep;
}
&.ant-picker-cell-today .ant-picker-cell-inner::before {
border-color: @color-gold-400;
}
}
.ant-picker-footer {
border-top: 1px solid @color-line-subtle;
}
.ant-picker-today-btn {
color: @color-gold-400;
}
}
// ==================== 移动端适配 ====================
@media (max-width: @mobile-breakpoint) {
.event-form-modal {
// Modal 整体尺寸
.ant-modal {
width: calc(100vw - 32px) !important;
max-width: 100% !important;
margin: @spacing-lg auto;
top: 0;
padding-bottom: 0;
}
.ant-modal-content {
max-height: calc(100vh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: @modal-border-radius-mobile;
}
// Modal 头部
.ant-modal-header {
padding: @spacing-md @spacing-lg;
flex-shrink: 0;
}
.ant-modal-title {
font-size: @font-size-md;
}
// Modal 内容区域
.ant-modal-body {
padding: @spacing-lg;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
// Modal 底部
.ant-modal-footer {
padding: @spacing-md @spacing-lg;
flex-shrink: 0;
}
// 表单项间距
.ant-form-item {
margin-bottom: @spacing-lg;
}
// 表单标签
.ant-form-item-label > label {
font-size: @font-size-sm;
height: auto;
}
// 输入框字体 - iOS 防止缩放需要 16px
.ant-input,
.ant-picker-input > input,
.ant-select-selection-search-input {
font-size: @font-size-md !important;
}
// 文本域高度
.ant-input-textarea textarea {
font-size: @font-size-md !important;
min-height: 120px;
}
// 模板按钮组
.template-buttons .ant-btn {
font-size: @font-size-xs;
padding: 2px @spacing-sm;
height: 26px;
}
// 股票选择器
.ant-select-selector {
min-height: 40px !important;
}
// 底部按钮
.modal-footer .ant-btn-primary {
font-size: @font-size-md;
height: 44px;
width: 100%;
}
}
}
// ==================== 动画 ====================
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}

View File

@@ -23,6 +23,8 @@ import {
message,
Space,
Spin,
ConfigProvider,
theme,
} from 'antd';
import type { SelectProps } from 'antd';
import {
@@ -34,7 +36,13 @@ import 'dayjs/locale/zh-cn';
import { useSelector } from 'react-redux';
import { useAppDispatch } from '@/store/hooks';
import { usePlanningData } from './PlanningContext';
import {
fetchAllEvents,
optimisticAddEvent,
replaceEvent,
removeEvent,
optimisticUpdateEvent,
} from '@/store/slices/planningSlice';
import './EventFormModal.less';
import type { InvestmentEvent, EventType } from '@/types';
import { logger } from '@/utils/logger';
@@ -184,7 +192,6 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
label = '事件',
apiEndpoint = 'investment-plans',
}) => {
const { loadAllData } = usePlanningData();
const dispatch = useAppDispatch();
const [form] = Form.useForm<FormData>();
const [saving, setSaving] = useState(false);
@@ -275,7 +282,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
setStockOptions(options.length > 0 ? options : watchlistOptions);
}, [allStocks, watchlistOptions]);
// 保存数据
// 保存数据(新建模式使用乐观更新)
const handleSave = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
@@ -315,28 +322,103 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
: `${base}/api/account/${apiEndpoint}`;
const method = mode === 'edit' ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(requestData),
});
if (response.ok) {
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
itemId: editingEvent?.id,
// ===== 新建模式:乐观更新 =====
if (mode === 'create') {
const tempId = -Date.now(); // 负数临时 ID避免与服务器 ID 冲突
const tempEvent: InvestmentEvent = {
id: tempId,
title: values.title,
});
message.success(mode === 'edit' ? '修改成功' : '添加成功');
onClose();
onSuccess();
loadAllData();
} else {
throw new Error('保存失败');
content: values.content || '',
description: values.content || '',
date: values.date.format('YYYY-MM-DD'),
event_date: values.date.format('YYYY-MM-DD'),
type: eventType,
stocks: stocksWithNames,
status: 'active',
source: 'user',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// ① 立即更新 UI
dispatch(optimisticAddEvent(tempEvent));
setSaving(false);
onClose(); // 立即关闭弹窗
// ② 后台发送 API 请求
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(requestData),
});
const data = await response.json();
if (response.ok && data.success) {
// ③ 用真实数据替换临时数据
dispatch(replaceEvent({ tempId, realEvent: data.data }));
logger.info('EventFormModal', `创建${label}成功`, { title: values.title });
message.success('添加成功');
onSuccess();
} else {
throw new Error(data.error || '创建失败');
}
} catch (error) {
// ④ 失败回滚
dispatch(removeEvent(tempId));
logger.error('EventFormModal', 'handleSave optimistic rollback', error);
message.error('创建失败,请重试');
}
return;
}
// ===== 编辑模式:乐观更新 =====
if (editingEvent) {
// 构建更新后的事件对象
const updatedEvent: InvestmentEvent = {
...editingEvent,
title: values.title,
content: values.content || '',
description: values.content || '',
date: values.date.format('YYYY-MM-DD'),
event_date: values.date.format('YYYY-MM-DD'),
stocks: stocksWithNames,
updated_at: new Date().toISOString(),
};
// ① 立即更新 UI
dispatch(optimisticUpdateEvent(updatedEvent));
setSaving(false);
onClose(); // 立即关闭弹窗
// ② 后台发送 API 请求
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(requestData),
});
if (response.ok) {
logger.info('EventFormModal', `更新${label}成功`, {
itemId: editingEvent.id,
title: values.title,
});
message.success('修改成功');
onSuccess();
} else {
throw new Error('保存失败');
}
} catch (error) {
// ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventFormModal', 'handleSave edit rollback', error);
message.error('修改失败,请重试');
}
return;
}
} catch (error) {
if (error instanceof Error && error.message !== '保存失败') {
@@ -350,7 +432,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
} finally {
setSaving(false);
}
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, dispatch, allStocks, watchlist]);
// 监听键盘快捷键 Ctrl + Enter
useEffect(() => {
@@ -393,7 +475,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
// 判断是否显示自选股列表
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
// 股票选择器选项配置
// 股票选择器选项配置(黑金主题)
const selectProps: SelectProps<string[]> = {
mode: 'multiple',
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
@@ -401,12 +483,15 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
onSearch: handleStockSearch,
loading: watchlistLoading || allStocksLoading,
notFoundContent: allStocksLoading ? (
<div style={{ textAlign: 'center', padding: '8px' }}>
<div style={{ textAlign: 'center', padding: '8px', color: 'rgba(255,255,255,0.6)' }}>
<Spin size="small" />
<span style={{ marginLeft: 8 }}>...</span>
</div>
) : '暂无结果',
) : <span style={{ color: 'rgba(255,255,255,0.4)' }}></span>,
options: stockOptions,
style: {
width: '100%',
},
onFocus: () => {
if (stockOptions.length === 0) {
setStockOptions(watchlistOptions);
@@ -416,41 +501,49 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
const { label: tagLabel, closable, onClose: onTagClose } = props;
return (
<Tag
color="blue"
closable={closable}
onClose={onTagClose}
style={{ marginRight: 3 }}
style={{
marginRight: 3,
background: 'rgba(212, 175, 55, 0.15)',
border: '1px solid rgba(212, 175, 55, 0.3)',
color: '#D4AF37',
}}
>
{tagLabel}
</Tag>
);
},
popupRender: (menu) => (
<>
<div style={{
background: '#1A1A2E',
border: '1px solid rgba(212, 175, 55, 0.2)',
borderRadius: '8px',
}}>
{isShowingWatchlist && watchlistOptions.length > 0 && (
<>
<div style={{ padding: '4px 8px 0' }}>
<span style={{ fontSize: 12, color: '#999' }}>
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
<div style={{ padding: '8px 12px 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)' }}>
<StarOutlined style={{ marginRight: 4, color: '#D4AF37' }} />
</span>
</div>
<Divider style={{ margin: '4px 0 0' }} />
<Divider style={{ margin: '4px 0 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
</>
)}
{menu}
{!isShowingWatchlist && searchText && (
<>
<Divider style={{ margin: '8px 0' }} />
<div style={{ padding: '0 8px 4px' }}>
<span style={{ fontSize: 12, color: '#999' }}>
<Divider style={{ margin: '8px 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
<div style={{ padding: '0 12px 8px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
<BulbOutlined style={{ marginRight: 4 }} />
</span>
</div>
</>
)}
</>
</div>
),
};
@@ -462,9 +555,47 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
return eventType === 'plan' ? '创建计划' : '创建复盘';
};
// 黑金主题样式
const modalStyles = {
mask: {
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
},
content: {
background: 'linear-gradient(135deg, #1A1A2E 0%, #0F0F1A 100%)',
border: '1px solid rgba(212, 175, 55, 0.2)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1)',
},
header: {
background: 'transparent',
borderBottom: '1px solid rgba(212, 175, 55, 0.1)',
padding: '16px 24px',
},
body: {
padding: '24px',
paddingTop: '24px',
},
footer: {
background: 'transparent',
borderTop: '1px solid rgba(212, 175, 55, 0.1)',
padding: '16px 24px',
},
};
return (
<Modal
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
title={
<span style={{
fontSize: '20px',
fontWeight: 700,
background: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
{`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
</span>
}
open={isOpen}
onCancel={onClose}
width={600}
@@ -472,25 +603,71 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
maskClosable={true}
keyboard
className="event-form-modal"
styles={modalStyles}
closeIcon={
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '16px' }}></span>
}
footer={
<div className="modal-footer">
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
onClick={handleSave}
loading={saving}
disabled={saving}
style={{
background: 'linear-gradient(135deg, #D4AF37 0%, #B8960C 100%)',
border: 'none',
color: '#0A0A14',
fontWeight: 600,
height: '40px',
padding: '0 24px',
borderRadius: '8px',
}}
>
{getButtonText()}
</Button>
</div>
}
>
<div ref={modalContentRef}>
{isOpen && <Form
form={form}
layout="horizontal"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: 'rgba(26, 26, 46, 0.8)',
colorBorder: 'rgba(212, 175, 55, 0.2)',
colorBorderSecondary: 'rgba(212, 175, 55, 0.1)',
colorText: 'rgba(255, 255, 255, 0.95)',
colorTextSecondary: 'rgba(255, 255, 255, 0.6)',
colorTextPlaceholder: 'rgba(255, 255, 255, 0.4)',
colorBgElevated: '#1A1A2E',
colorFillSecondary: 'rgba(212, 175, 55, 0.1)',
},
components: {
Input: {
activeBorderColor: '#D4AF37',
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
},
Select: {
optionActiveBg: 'rgba(212, 175, 55, 0.1)',
optionSelectedBg: 'rgba(212, 175, 55, 0.2)',
optionSelectedColor: '#D4AF37',
},
DatePicker: {
activeBorderColor: '#D4AF37',
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
},
},
}}
>
<div ref={modalContentRef}>
{isOpen && <Form
form={form}
layout="horizontal"
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
labelAlign="left"
requiredMark={false}
initialValues={{
@@ -501,7 +678,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 标题 */}
<Form.Item
name="title"
label={<span style={{ fontWeight: 600 }}> <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}> <span style={{ color: '#D4AF37' }}>*</span></span>}
rules={[
{ required: true, message: '请输入标题' },
{ max: 50, message: '标题不能超过50个字符' },
@@ -511,17 +688,31 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
placeholder={getTitlePlaceholder()}
maxLength={50}
showCount
styles={{
input: {
background: 'rgba(26, 26, 46, 0.8)',
borderColor: 'rgba(212, 175, 55, 0.2)',
color: 'rgba(255, 255, 255, 0.95)',
},
count: {
color: 'rgba(255, 255, 255, 0.4)',
},
}}
/>
</Form.Item>
{/* 日期 */}
<Form.Item
name="date"
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#D4AF37' }}>*</span></span>}
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker
style={{ width: '100%' }}
style={{
width: '100%',
background: 'rgba(26, 26, 46, 0.8)',
borderColor: 'rgba(212, 175, 55, 0.2)',
}}
format="YYYY-MM-DD"
placeholder="选择日期"
allowClear={false}
@@ -531,7 +722,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 描述/内容 - 上下布局 */}
<Form.Item
name="content"
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#D4AF37' }}>*</span></span>}
rules={[{ required: true, message: '请输入内容' }]}
labelCol={{ span: 24 }}
wrapperCol={{ span: 24 }}
@@ -542,18 +733,28 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
rows={8}
showCount
maxLength={2000}
style={{ resize: 'vertical' }}
style={{
resize: 'vertical',
background: 'rgba(26, 26, 46, 0.8)',
borderColor: 'rgba(212, 175, 55, 0.2)',
color: 'rgba(255, 255, 255, 0.95)',
}}
/>
</Form.Item>
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
<div style={{ marginBottom: 24 }}>
<Space wrap size="small" className="template-buttons">
<Space wrap size="small">
{templates.map((template) => (
<Button
key={template.label}
size="small"
onClick={() => handleInsertTemplate(template)}
style={{
background: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.2)',
color: 'rgba(255, 255, 255, 0.6)',
}}
>
{template.label}
</Button>
@@ -564,12 +765,13 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 关联股票 */}
<Form.Item
name="stocks"
label={<span style={{ fontWeight: 600 }}></span>}
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}></span>}
>
<Select {...selectProps} />
</Form.Item>
</Form>}
</div>
</Form>}
</div>
</ConfigProvider>
</Modal>
);
};

View File

@@ -1,5 +1,5 @@
/**
* EventPanel -
* EventPanel - (Redux )
*
*
* props
@@ -17,15 +17,23 @@ import {
Spinner,
Center,
Icon,
useToast,
} from '@chakra-ui/react';
import { FiFileText } from 'react-icons/fi';
import { usePlanningData } from './PlanningContext';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import {
fetchAllEvents,
removeEvent,
selectPlans,
selectReviews,
selectPlanningLoading,
} from '@/store/slices/planningSlice';
import { getApiBase } from '@/utils/apiConfig';
import { EventFormModal } from './EventFormModal';
import { EventCard } from './EventCard';
import { FUIEventCard } from './FUIEventCard';
import type { InvestmentEvent } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
/**
* EventPanel Props
@@ -51,15 +59,16 @@ export const EventPanel: React.FC<EventPanelProps> = ({
label,
openModalTrigger,
}) => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
} = usePlanningData();
const dispatch = useAppDispatch();
const toast = useToast();
// Redux 状态
const plans = useAppSelector(selectPlans);
const reviews = useAppSelector(selectReviews);
const loading = useAppSelector(selectPlanningLoading);
// 根据类型选择事件列表
const events = type === 'plan' ? plans : reviews;
// 弹窗状态
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
@@ -69,9 +78,6 @@ export const EventPanel: React.FC<EventPanelProps> = ({
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
// 筛选事件列表(按类型过滤,排除系统事件)
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
// 监听外部触发打开新建模态框(修复 bug只在值变化时触发
useEffect(() => {
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
@@ -99,14 +105,17 @@ export const EventPanel: React.FC<EventPanelProps> = ({
setEditingItem(null);
};
// 删除数据
// 删除数据 - 乐观更新模式
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
// ① 立即从 UI 移除
dispatch(removeEvent(id));
// ② 后台发送 API 请求
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
@@ -118,23 +127,34 @@ export const EventPanel: React.FC<EventPanelProps> = ({
status: 'success',
duration: 2000,
});
loadAllData();
} else {
throw new Error('删除失败');
}
} catch (error) {
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
// ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
toast({
title: '删除失败',
title: '删除失败,请重试',
status: 'error',
duration: 3000,
});
}
};
// 刷新数据
const handleRefresh = useCallback(() => {
dispatch(fetchAllEvents());
}, [dispatch]);
// 使用 useCallback 优化回调函数
const handleEdit = useCallback((item: InvestmentEvent) => {
handleOpenModal(item);
}, []);
// 颜色主题
const secondaryText = 'rgba(255, 255, 255, 0.6)';
return (
<Box>
<VStack align="stretch" spacing={4}>
@@ -150,17 +170,13 @@ export const EventPanel: React.FC<EventPanelProps> = ({
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={{ base: 3, md: 4 }}>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={{ base: 3, md: 4 }}>
{events.map(event => (
<EventCard
<FUIEventCard
key={event.id}
event={event}
variant="list"
colorScheme={colorScheme}
label={label}
textColor={textColor}
secondaryText={secondaryText}
cardBg={cardBg}
onEdit={handleEdit}
onDelete={handleDelete}
/>
@@ -176,7 +192,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
mode={modalMode}
eventType={type}
editingEvent={editingItem}
onSuccess={loadAllData}
onSuccess={handleRefresh}
label={label}
apiEndpoint="investment-plans"
/>

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