Compare commits

..

130 Commits

Author SHA1 Message Date
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
234 changed files with 19095 additions and 13417 deletions

1614
CLAUDE.md

File diff suppressed because it is too large Load Diff

573
app.py
View File

@@ -217,6 +217,30 @@ def get_target_and_prev_trading_day(event_datetime):
# 应用启动时加载交易日数据
load_trading_days()
def is_trading_hours():
"""
判断当前是否在交易时间段内
交易时间:交易日的 9:00-15:00含午休时间因为事件可能在午休发布
Returns:
bool: True 表示在交易时间段False 表示非交易时间
"""
now = datetime.now()
today = now.date()
current_time = now.time()
# 判断今天是否为交易日
if today not in trading_days_set:
return False
# 判断是否在 9:00-15:00 之间
market_open = dt_time(9, 0)
market_close = dt_time(15, 0)
return market_open <= current_time <= market_close
engine = create_engine(
"mysql+pymysql://root:Zzl33818!@127.0.0.1:3306/stock?charset=utf8mb4",
echo=False,
@@ -300,6 +324,106 @@ def delete_verification_code(key):
print(f"📦 验证码存储: Redis, 过期时间: {VERIFICATION_CODE_EXPIRE}")
# ============ 事件列表 Redis 缓存(智能 TTL 策略) ============
EVENTS_CACHE_PREFIX = "events:cache:"
EVENTS_CACHE_TTL_TRADING = 20 # 交易时间缓存 TTL
EVENTS_CACHE_TTL_NON_TRADING = 600 # 非交易时间缓存 TTL10分钟
def generate_events_cache_key(args_dict):
"""
根据请求参数生成缓存 Key
使用 MD5 哈希保证 key 长度固定且唯一
Args:
args_dict: 请求参数字典
Returns:
str: 缓存 key格式为 events:cache:{md5_hash}
"""
import hashlib
# 过滤掉空值,并排序保证顺序一致
filtered_params = {k: v for k, v in sorted(args_dict.items())
if v is not None and v != '' and v != 'all'}
# 生成参数字符串并计算 MD5
params_str = json.dumps(filtered_params, sort_keys=True)
params_hash = hashlib.md5(params_str.encode()).hexdigest()
return f"{EVENTS_CACHE_PREFIX}{params_hash}"
def get_events_cache(cache_key):
"""
从 Redis 获取事件列表缓存
Args:
cache_key: 缓存 key
Returns:
dict or None: 缓存的响应数据,如果不存在或出错返回 None
"""
try:
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
return None
except Exception as e:
print(f"❌ Redis 获取事件缓存失败: {e}")
return None
def set_events_cache(cache_key, data):
"""
将事件列表数据存入 Redis 缓存
根据是否在交易时间自动选择 TTL
Args:
cache_key: 缓存 key
data: 要缓存的响应数据
Returns:
bool: 是否成功
"""
try:
# 根据交易时间选择 TTL
ttl = EVENTS_CACHE_TTL_TRADING if is_trading_hours() else EVENTS_CACHE_TTL_NON_TRADING
redis_client.setex(cache_key, ttl, json.dumps(data, ensure_ascii=False))
return True
except Exception as e:
print(f"❌ Redis 存储事件缓存失败: {e}")
return False
def clear_events_cache():
"""
清除所有事件列表缓存
用于事件数据更新后主动刷新缓存
"""
try:
# 使用 SCAN 命令迭代删除,避免 KEYS 命令阻塞
cursor = 0
deleted_count = 0
while True:
cursor, keys = redis_client.scan(cursor, match=f"{EVENTS_CACHE_PREFIX}*", count=100)
if keys:
redis_client.delete(*keys)
deleted_count += len(keys)
if cursor == 0:
break
if deleted_count > 0:
print(f"🗑️ 已清除 {deleted_count} 个事件缓存")
return deleted_count
except Exception as e:
print(f"❌ 清除事件缓存失败: {e}")
return 0
print(f"📦 事件列表缓存: 交易时间 {EVENTS_CACHE_TTL_TRADING}s / 非交易时间 {EVENTS_CACHE_TTL_NON_TRADING}s")
# ============ 微信登录 Session 管理Redis 存储,支持多进程) ============
WECHAT_SESSION_EXPIRE = 300 # Session 过期时间5分钟
WECHAT_SESSION_PREFIX = "wechat_session:"
@@ -10517,8 +10641,26 @@ def get_stock_list():
def api_get_events():
"""
获取事件列表API - 支持筛选、排序、分页,兼容前端调用
Redis 缓存策略:
- 交易时间(交易日 9:00-15:00缓存 20 秒
- 非交易时间:缓存 10 分钟
"""
try:
# ==================== Redis 缓存检查 ====================
# 获取所有请求参数用于生成缓存 key
cache_params = dict(request.args)
cache_key = generate_events_cache_key(cache_params)
# 尝试从缓存获取
cached_response = get_events_cache(cache_key)
if cached_response:
# 添加缓存命中标记(可选,用于调试)
cached_response['_cached'] = True
cached_response['_cache_ttl'] = EVENTS_CACHE_TTL_TRADING if is_trading_hours() else EVENTS_CACHE_TTL_NON_TRADING
return jsonify(cached_response)
# ==================== 缓存未命中,执行数据库查询 ====================
# 分页参数
page = max(1, request.args.get('page', 1, type=int))
per_page = min(100, max(1, request.args.get('per_page', 10, type=int)))
@@ -10776,7 +10918,9 @@ def api_get_events():
if search_query:
applied_filters['search_query'] = search_query
applied_filters['search_type'] = search_type
return jsonify({
# 构建响应数据
response_data = {
'success': True,
'data': {
'events': events_data,
@@ -10793,7 +10937,12 @@ def api_get_events():
'total_count': paginated.total
}
}
})
}
# ==================== 存入 Redis 缓存 ====================
set_events_cache(cache_key, response_data)
return jsonify(response_data)
except Exception as e:
app.logger.error(f"获取事件列表出错: {str(e)}", exc_info=True)
return jsonify({
@@ -10844,6 +10993,423 @@ def get_hot_events():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/events/mainline', methods=['GET'])
def get_events_by_mainline():
"""
获取按主线lv1/lv2/lv3概念分组的事件列表
逻辑:
1. 根据筛选条件获取事件列表
2. 通过 related_concepts 表关联概念
3. 调用 concept-api/hierarchy 获取概念的层级归属
4. 按指定层级分组返回
参数:
- recent_days: 近N天默认7天当有 start_date/end_date 时忽略)
- start_date: 开始时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss
- end_date: 结束时间(精确时间范围,格式 YYYY-MM-DD HH:mm:ss
- importance: 重要性筛选S,A,B,C 或 all
- group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA)默认lv2
返回:
{
"success": true,
"data": {
"mainlines": [
{
"lv2_id": "L2_AI_INFRA",
"lv2_name": "AI基础设施 (算力/CPO/PCB)",
"lv1_name": "TMT (科技/媒体/通信)",
"event_count": 15,
"events": [...]
},
...
],
"total_events": 100,
"ungrouped_count": 5,
"group_by": "lv2",
"hierarchy_options": {...} // 层级选项供前端下拉框使用
}
}
"""
try:
import requests
from datetime import datetime, timedelta
from sqlalchemy.orm import joinedload
from sqlalchemy import exists
# 获取请求参数
recent_days = request.args.get('recent_days', type=int)
start_date_str = request.args.get('start_date', '')
end_date_str = request.args.get('end_date', '')
importance = request.args.get('importance', 'all')
group_by = request.args.get('group_by', 'lv2') # lv1/lv2/lv3 或具体ID
# 计算日期范围
# 优先使用精确时间范围,其次使用 recent_days
if start_date_str and end_date_str:
try:
since_date = datetime.strptime(start_date_str, '%Y-%m-%d %H:%M:%S')
until_date = datetime.strptime(end_date_str, '%Y-%m-%d %H:%M:%S')
app.logger.info(f'[mainline] 使用精确时间范围: {since_date} - {until_date}')
except ValueError as e:
app.logger.warning(f'[mainline] 时间格式解析失败: {e}, 降级使用 recent_days')
since_date = datetime.now() - timedelta(days=recent_days or 7)
until_date = None
elif recent_days:
since_date = datetime.now() - timedelta(days=recent_days)
until_date = None
app.logger.info(f'[mainline] 使用 recent_days: {recent_days}')
else:
# 默认7天
since_date = datetime.now() - timedelta(days=7)
until_date = None
app.logger.info(f'[mainline] 使用默认时间范围: 近7天')
# ==================== 1. 获取概念层级映射 ====================
# 调用 concept-api 获取层级结构
concept_hierarchy_map = {} # { concept_name: { lv1, lv2, lv3, lv1_id, lv2_id, lv3_id } }
hierarchy_options = {'lv1': [], 'lv2': [], 'lv3': []} # 层级选项供前端下拉框使用
try:
# 从本地文件读取概念层级结构
import json
import os
hierarchy_file = os.path.join(os.path.dirname(__file__), 'concept_hierarchy_v3.json')
with open(hierarchy_file, 'r', encoding='utf-8') as f:
hierarchy_data = json.load(f)
hierarchy_list = hierarchy_data.get('hierarchy', [])
# 构建概念名称 -> 完整层级映射 + 层级选项
# 结构: L1 -> L2 -> L3 -> concepts (concepts 只在 L3 层)
for lv1 in hierarchy_list:
lv1_name = lv1.get('lv1', '')
lv1_id = lv1.get('lv1_id', '')
# 添加 lv1 选项
if lv1_id and lv1_name:
hierarchy_options['lv1'].append({
'id': lv1_id,
'name': lv1_name
})
for lv2 in lv1.get('children', []) or []:
lv2_name = lv2.get('lv2', '')
lv2_id = lv2.get('lv2_id', '')
# 添加 lv2 选项
if lv2_id and lv2_name:
hierarchy_options['lv2'].append({
'id': lv2_id,
'name': lv2_name,
'lv1_id': lv1_id,
'lv1_name': lv1_name
})
# L3 层包含 concepts
for lv3 in lv2.get('children', []) or []:
lv3_name = lv3.get('lv3', '')
lv3_id = lv3.get('lv3_id', '')
# 添加 lv3 选项
if lv3_id and lv3_name:
hierarchy_options['lv3'].append({
'id': lv3_id,
'name': lv3_name,
'lv2_id': lv2_id,
'lv2_name': lv2_name,
'lv1_id': lv1_id,
'lv1_name': lv1_name
})
for concept in lv3.get('concepts', []) or []:
concept_name = concept if isinstance(concept, str) else concept.get('name', '')
if concept_name:
concept_hierarchy_map[concept_name] = {
'lv1': lv1_name,
'lv1_id': lv1_id,
'lv2': lv2_name,
'lv2_id': lv2_id,
'lv3': lv3_name,
'lv3_id': lv3_id
}
app.logger.info(f'[mainline] 加载概念层级映射: {len(concept_hierarchy_map)} 个概念, lv1: {len(hierarchy_options["lv1"])}, lv2: {len(hierarchy_options["lv2"])}, lv3: {len(hierarchy_options["lv3"])}')
except Exception as e:
app.logger.warning(f'[mainline] 获取概念层级失败: {e}')
# ==================== 2. 查询事件及其关联概念 ====================
query = Event.query.options(joinedload(Event.creator))
# 只返回有关联股票的事件
query = query.filter(
exists().where(RelatedStock.event_id == Event.id)
)
# 状态筛选
query = query.filter(Event.status == 'active')
# 日期筛选
query = query.filter(Event.created_at >= since_date)
if until_date:
query = query.filter(Event.created_at <= until_date)
# 重要性筛选
if importance != 'all':
if ',' in importance:
importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()]
query = query.filter(Event.importance.in_(importance_list))
else:
query = query.filter(Event.importance == importance)
# 按时间倒序
query = query.order_by(Event.created_at.desc())
# 获取事件(提高限制以支持主线模式显示更多数据)
events = query.limit(2000).all()
app.logger.info(f'[mainline] 查询到 {len(events)} 个事件')
# ==================== 3. 获取事件的关联概念 ====================
event_ids = [e.id for e in events]
# 批量查询 related_concepts
related_concepts_query = db.session.query(
RelatedConcepts.event_id,
RelatedConcepts.concept
).filter(RelatedConcepts.event_id.in_(event_ids)).all()
# 构建 event_id -> concepts 映射
event_concepts_map = {} # { event_id: [concept1, concept2, ...] }
for event_id, concept in related_concepts_query:
if event_id not in event_concepts_map:
event_concepts_map[event_id] = []
event_concepts_map[event_id].append(concept)
app.logger.warning(f'[mainline] 查询到 {len(related_concepts_query)} 条概念关联')
# 调试:输出一些 related_concepts 的样本
sample_concepts = list(set([c for _, c in related_concepts_query[:100]]))[:20]
app.logger.warning(f'[mainline] related_concepts 样本: {sample_concepts}')
# 调试:输出一些 hierarchy 的样本
hierarchy_sample = list(concept_hierarchy_map.keys())[:20]
app.logger.warning(f'[mainline] hierarchy 概念样本: {hierarchy_sample}')
# ==================== 4. 按 lv2 分组事件 ====================
mainline_groups = {} # { lv2_id: { info: {...}, events: [...] } }
ungrouped_events = []
def find_concept_hierarchy(concept_name):
"""查找概念的层级信息(支持多种匹配方式)"""
if not concept_name:
return None
# 1. 精确匹配
if concept_name in concept_hierarchy_map:
return concept_hierarchy_map[concept_name]
# 2. 去掉常见前缀后缀再匹配
# 例如 "消费电子-玄玑感知系统" -> "消费电子"
concept_clean = concept_name.replace('-', ' ').replace('_', ' ').split()[0] if '-' in concept_name or '_' in concept_name else concept_name
if concept_clean in concept_hierarchy_map:
return concept_hierarchy_map[concept_clean]
# 3. 包含匹配(双向)
for key in concept_hierarchy_map:
if concept_name in key or key in concept_name:
return concept_hierarchy_map[key]
# 4. 关键词匹配 - 提取关键词进行匹配
# 例如 "华为鸿蒙" 能匹配到包含 "华为" 或 "鸿蒙" 的 hierarchy
keywords_to_check = ['华为', '鸿蒙', '特斯拉', '比亚迪', '英伟达', '苹果', '小米',
'AI', '机器人', '光伏', '储能', '锂电', '芯片', '半导体',
'无人机', '低空', '汽车', '医药', '消费电子', '算力', 'GPU',
'大模型', '智能体', 'DeepSeek', 'KIMI', '固态电池']
for kw in keywords_to_check:
if kw in concept_name:
# 找 hierarchy 中包含这个关键词的
for key in concept_hierarchy_map:
if kw in key:
return concept_hierarchy_map[key]
return None
# 判断分组方式
is_specific_id = group_by.startswith('L1_') or group_by.startswith('L2_') or group_by.startswith('L3_')
for event in events:
concepts = event_concepts_map.get(event.id, [])
# 找出该事件所属的层级信息
event_groups = set() # 存储 (group_id, group_name, parent_info) 元组
for concept in concepts:
hierarchy = find_concept_hierarchy(concept)
if hierarchy:
if is_specific_id:
# 筛选特定概念ID
if group_by.startswith('L1_') and hierarchy['lv1_id'] == group_by:
event_groups.add((hierarchy['lv2_id'], hierarchy['lv2'], hierarchy['lv1'], hierarchy.get('lv3', ''), hierarchy.get('lv3_id', '')))
elif group_by.startswith('L2_') and hierarchy['lv2_id'] == group_by:
event_groups.add((hierarchy.get('lv3_id', hierarchy['lv2_id']), hierarchy.get('lv3', hierarchy['lv2']), hierarchy['lv2'], hierarchy['lv1'], ''))
elif group_by.startswith('L3_') and hierarchy.get('lv3_id') == group_by:
event_groups.add((hierarchy['lv3_id'], hierarchy['lv3'], hierarchy['lv2'], hierarchy['lv1'], ''))
elif group_by == 'lv1':
event_groups.add((hierarchy['lv1_id'], hierarchy['lv1'], '', '', ''))
elif group_by == 'lv3':
if hierarchy.get('lv3_id'):
event_groups.add((hierarchy['lv3_id'], hierarchy['lv3'], hierarchy['lv2'], hierarchy['lv1'], ''))
else: # 默认 lv2
event_groups.add((hierarchy['lv2_id'], hierarchy['lv2'], hierarchy['lv1'], '', ''))
# 事件数据
event_data = {
'id': event.id,
'title': event.title,
'description': event.description,
'importance': event.importance,
'created_at': event.created_at.isoformat() if event.created_at else None,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'expectation_surprise_score': event.expectation_surprise_score,
'hot_score': event.hot_score,
'related_concepts': [{'concept': c} for c in concepts],
'creator': {
'username': event.creator.username if event.creator else 'Anonymous'
}
}
if event_groups:
# 添加到每个相关的分组
for group_info in event_groups:
group_id = group_info[0]
group_name = group_info[1]
parent_name = group_info[2] if len(group_info) > 2 else ''
grandparent_name = group_info[3] if len(group_info) > 3 else ''
if group_id not in mainline_groups:
mainline_groups[group_id] = {
'group_id': group_id,
'group_name': group_name,
'parent_name': parent_name,
'grandparent_name': grandparent_name,
# 兼容旧字段
'lv2_id': group_id if group_by == 'lv2' or group_by.startswith('L1_') else None,
'lv2_name': group_name if group_by == 'lv2' or group_by.startswith('L1_') else parent_name,
'lv1_name': parent_name if group_by == 'lv2' else grandparent_name,
'lv3_name': group_name if group_by == 'lv3' or group_by.startswith('L2_') else None,
'events': []
}
mainline_groups[group_id]['events'].append(event_data)
else:
ungrouped_events.append(event_data)
# ==================== 5. 获取概念涨跌幅(根据 group_by 参数) ====================
price_map = {}
# 确定当前分组层级和对应的数据库类型
if group_by == 'lv1' or group_by.startswith('L1_'):
current_level = 'lv1'
db_concept_type = 'lv1'
name_prefix = '[一级] '
name_field = 'lv1_name'
elif group_by == 'lv3' or group_by.startswith('L2_'):
current_level = 'lv3'
db_concept_type = 'lv3'
name_prefix = '[三级] '
name_field = 'lv3_name'
else: # lv2 或 L3_ 开头(查看 lv3 下的具体分类,显示 lv2 涨跌幅)
current_level = 'lv2'
db_concept_type = 'lv2'
name_prefix = '[二级] '
name_field = 'lv2_name'
try:
# 获取所有对应层级的名称
group_names = [group.get('group_name') or group.get(name_field) for group in mainline_groups.values()]
group_names = [n for n in group_names if n] # 过滤掉空值
if group_names:
# 数据库中的 concept_name 带有前缀,需要添加前缀来匹配
names_with_prefix = [f'{name_prefix}{name}' for name in group_names]
# 查询 concept_daily_stats 表获取最新涨跌幅
price_sql = text('''
SELECT concept_name, avg_change_pct, trade_date
FROM concept_daily_stats
WHERE concept_type = :concept_type
AND concept_name IN :names
AND trade_date = (
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = :concept_type
)
''')
price_result = db.session.execute(price_sql, {
'concept_type': db_concept_type,
'names': tuple(names_with_prefix)
}).fetchall()
for row in price_result:
# 去掉前缀,用原始名称作为 key
original_name = row.concept_name.replace(name_prefix, '') if row.concept_name else ''
price_map[original_name] = {
'avg_change_pct': float(row.avg_change_pct) if row.avg_change_pct else None,
'trade_date': str(row.trade_date) if row.trade_date else None
}
app.logger.info(f'[mainline] 获取 {current_level} 涨跌幅: {len(price_map)} 条, 查询名称数量: {len(group_names)}')
except Exception as price_err:
app.logger.warning(f'[mainline] 获取 {current_level} 涨跌幅失败: {price_err}')
# ==================== 6. 整理返回数据 ====================
mainlines = []
for group_id, group in mainline_groups.items():
# 按时间倒序排列(不限制数量)
group['events'] = sorted(
group['events'],
key=lambda x: x['created_at'] or '',
reverse=True
)
group['event_count'] = len(group['events'])
# 添加涨跌幅数据(根据当前分组层级)
group_name = group.get('group_name') or group.get(name_field, '')
if group_name in price_map:
group['avg_change_pct'] = price_map[group_name]['avg_change_pct']
group['price_date'] = price_map[group_name]['trade_date']
else:
group['avg_change_pct'] = None
group['price_date'] = None
mainlines.append(group)
# 按事件数量排序
mainlines.sort(key=lambda x: x['event_count'], reverse=True)
return jsonify({
'success': True,
'data': {
'mainlines': mainlines,
'total_events': len(events),
'mainline_count': len(mainlines),
'ungrouped_count': len(ungrouped_events),
'group_by': group_by,
'hierarchy_options': hierarchy_options,
# 调试信息
'_debug': {
'hierarchy_count': len(concept_hierarchy_map),
'hierarchy_sample': hierarchy_sample[:10],
'related_concepts_sample': sample_concepts[:10],
'related_concepts_count': len(related_concepts_query)
}
}
})
except Exception as e:
app.logger.error(f'[mainline] 错误: {e}', exc_info=True)
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/events/keywords/popular', methods=['GET'])
def get_popular_keywords():
"""获取热门关键词"""
@@ -11681,6 +12247,9 @@ def broadcast_new_event(event):
else:
print(f'[WebSocket] 已推送新事件到房间: events_all')
# 清除事件列表缓存,确保用户刷新页面时获取最新数据
clear_events_cache()
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
except Exception as e:

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -1,5 +1,5 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件 - 折叠手风琴样式
// 相关概念区组件 - 便当盒网格布局
import React, { useState, useEffect } from 'react';
import {
@@ -10,94 +10,72 @@ import {
Spinner,
Text,
Badge,
VStack,
SimpleGrid,
HStack,
Icon,
Collapse,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
/**
* 单个概念组件(手风琴项
* 单个概念卡片组件(便当盒样式
*/
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => {
const itemBg = useColorModeValue('white', 'gray.700');
const itemHoverBg = useColorModeValue('gray.50', 'gray.650');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const conceptColor = useColorModeValue('blue.600', 'blue.300');
const reasonBg = useColorModeValue('blue.50', 'gray.800');
const reasonColor = useColorModeValue('gray.700', 'gray.200');
const iconColor = useColorModeValue('gray.500', 'gray.400');
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 (
<Box
borderWidth="1px"
borderColor={borderColor}
<Tooltip
label={concept.reason || concept.concept}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={2}
borderRadius="md"
overflow="hidden"
bg={itemBg}
maxW="300px"
fontSize="xs"
>
{/* 概念标题行 - 可点击展开 */}
<Flex
<Box
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
px={3}
py={2.5}
py={2}
cursor="pointer"
align="center"
justify="space-between"
_hover={{ bg: itemHoverBg }}
onClick={onToggle}
transition="background 0.2s"
onClick={handleClick}
_hover={{
bg: cardHoverBg,
transform: 'translateY(-1px)',
boxShadow: 'sm',
}}
transition="all 0.15s ease"
textAlign="center"
>
<HStack spacing={2} flex={1}>
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
color={iconColor}
boxSize={4}
transition="transform 0.2s"
/>
<Text
fontSize="sm"
fontWeight="medium"
color={conceptColor}
cursor="pointer"
_hover={{ textDecoration: 'underline' }}
onClick={(e) => {
e.stopPropagation();
onNavigate(concept);
}}
>
{concept.concept}
</Text>
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
AI 分析
</Badge>
</HStack>
</Flex>
{/* 关联原因 - 可折叠 */}
<Collapse in={isExpanded} animateOpacity>
<Box
px={4}
py={3}
bg={reasonBg}
borderTop="1px solid"
borderTopColor={borderColor}
<Text
fontSize="sm"
fontWeight="semibold"
color={conceptColor}
noOfLines={1}
>
<Text
fontSize="sm"
color={reasonColor}
lineHeight="1.8"
whiteSpace="pre-wrap"
>
{concept.reason || '暂无关联原因说明'}
</Text>
</Box>
</Collapse>
</Box>
{concept.concept}
</Text>
</Box>
</Tooltip>
);
};
@@ -120,16 +98,14 @@ const RelatedConceptsSection = ({
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 记录每个概念的展开状态
const [expandedItems, setExpandedItems] = useState({});
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 countBadgeBg = useColorModeValue('blue.100', 'blue.800');
const countBadgeColor = useColorModeValue('blue.700', 'blue.200');
// 颜色配置 - 使用深色主题固定颜色
const sectionBg = 'transparent';
const headingColor = '#e2e8f0';
const textColor = '#a0aec0';
const countBadgeBg = '#3182ce';
const countBadgeColor = '#ffffff';
// 获取相关概念
useEffect(() => {
@@ -162,10 +138,6 @@ const RelatedConceptsSection = ({
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
setConcepts(data.data);
// 默认展开第一个
if (data.data.length > 0) {
setExpandedItems({ 0: true });
}
} else {
setConcepts([]);
}
@@ -182,18 +154,6 @@ const RelatedConceptsSection = ({
fetchConcepts();
}, [eventId]);
// 切换某个概念的展开状态
const toggleItem = (index) => {
if (isLocked && onLockedClick) {
onLockedClick();
return;
}
setExpandedItems(prev => ({
...prev,
[index]: !prev[index]
}));
};
// 跳转到概念中心
const handleNavigate = (concept) => {
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
@@ -237,7 +197,7 @@ const RelatedConceptsSection = ({
</HStack>
</Flex>
{/* 概念列表 - 手风琴样式 */}
{/* 概念列表 - 便当盒网格布局 */}
{hasNoConcepts ? (
<Box py={2}>
{error ? (
@@ -247,17 +207,17 @@ const RelatedConceptsSection = ({
)}
</Box>
) : (
<VStack spacing={2} align="stretch">
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
{concepts.map((concept, index) => (
<ConceptItem
<ConceptCard
key={concept.id || index}
concept={concept}
isExpanded={!!expandedItems[index]}
onToggle={() => toggleItem(index)}
onNavigate={handleNavigate}
isLocked={isLocked}
onLockedClick={onLockedClick}
/>
))}
</VStack>
</SimpleGrid>
)}
</Box>
);

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

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

@@ -22,6 +22,7 @@
import React, { useState, useCallback, memo, Suspense } from 'react';
import {
Box,
Flex,
Tabs,
TabList,
TabPanels,
@@ -30,7 +31,6 @@ import {
Icon,
HStack,
Text,
Spacer,
Center,
Spinner,
} from '@chakra-ui/react';
@@ -45,6 +45,8 @@ export interface SubTabConfig {
name: string;
icon?: IconType | ComponentType;
component?: ComponentType<any>;
/** 自定义 Suspense fallback如骨架屏 */
fallback?: React.ReactNode;
}
/**
@@ -93,6 +95,16 @@ export interface SubTabTheme {
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 风格
*/
@@ -138,6 +150,8 @@ export interface SubTabContainerProps {
rightElement?: React.ReactNode;
/** 紧凑模式 - 移除 TabList 的外边距 */
compact?: boolean;
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
size?: SubTabSize;
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
@@ -152,7 +166,10 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
isLazy = true,
rightElement,
compact = false,
size = 'md',
}) => {
// 获取尺寸配置
const sizeConfig = SIZE_CONFIG[size];
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
@@ -164,6 +181,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
() => new Set([controlledIndex ?? defaultIndex])
);
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
() => ({ [controlledIndex ?? defaultIndex]: 1 })
);
// 合并主题
const theme: SubTabTheme = {
...THEME_PRESETS[themePreset],
@@ -175,6 +197,9 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
*/
const handleTabChange = useCallback(
(newIndex: number) => {
// 保存当前滚动位置,防止 Tab 切换时页面跳转
const scrollY = window.scrollY;
const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey);
@@ -184,9 +209,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
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]
);
@@ -200,28 +236,18 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
index={currentIndex}
onChange={handleTabChange}
>
{/* TabList - 玻璃态导航栏 */}
<TabList
{/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
<Flex
bg={theme.bg}
backdropFilter="blur(20px)"
sx={{ WebkitBackdropFilter: 'blur(20px)' }}
borderBottom="1px solid"
borderColor={theme.borderColor}
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
mx={compact ? 0 : 2}
mb={compact ? 0 : 2}
px={3}
py={compact ? 2 : 3}
flexWrap="nowrap"
gap={2}
alignItems="center"
overflowX="auto"
position="relative"
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
alignItems="center"
>
{/* 顶部金色光条 */}
<Box
@@ -234,98 +260,133 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
/>
{tabs.map((tab, idx) => {
const isSelected = idx === currentIndex;
{/* 左侧:可滚动的 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={6}
py={3}
fontSize="15px"
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={2}>
{tab.icon && (
<Icon
as={tab.icon}
boxSize={4}
opacity={isSelected ? 1 : 0.7}
transition="opacity 0.2s"
/>
)}
<Text>{tab.name}</Text>
</HStack>
</Tab>
);
})}
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 && (
<>
<Spacer />
<Box flexShrink={0}>{rightElement}</Box>
</>
<Box
flexShrink={0}
pr={3}
pl={2}
py={compact ? 2 : sizeConfig.py}
borderLeft="1px solid"
borderColor={DEEP_SPACE.borderGold}
>
{rightElement}
</Box>
)}
</TabList>
</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={
<Center py={20}>
<Spinner
size="lg"
color={DEEP_SPACE.textGold}
thickness="3px"
speed="0.8s"
/>
</Center>
tab.fallback || (
<Center py={20}>
<Spinner
size="lg"
color={DEEP_SPACE.textGold}
thickness="3px"
speed="0.8s"
/>
</Center>
)
}
>
<Component {...componentProps} />
<Component
{...componentProps}
isActive={isActive}
activationKey={activationCounts[idx] || 0}
/>
</Suspense>
) : null}
</TabPanel>

View File

@@ -28,6 +28,8 @@ export interface TabPanelContainerProps {
loadingMessage?: string;
/** 加载状态高度 */
loadingHeight?: string;
/** 自定义骨架屏组件,优先于默认 Spinner */
skeleton?: React.ReactNode;
/** 子组件间距,默认 6 */
spacing?: number;
/** 内边距,默认 4 */
@@ -74,6 +76,7 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
loading = false,
loadingMessage = '加载中...',
loadingHeight = '200px',
skeleton,
spacing = 6,
padding = 4,
showDisclaimer = false,
@@ -81,6 +84,10 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
children,
}) => {
if (loading) {
// 如果提供了自定义骨架屏,使用骨架屏;否则使用默认 Spinner
if (skeleton) {
return <>{skeleton}</>;
}
return <LoadingState message={loadingMessage} height={loadingHeight} />;
}

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

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

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

@@ -21,6 +21,9 @@ 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高度
};
/**

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'
}
];

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 // 层级信息(用于按主线分组)
};
});
}
@@ -1088,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 }
@@ -1112,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];
@@ -1163,11 +1352,33 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
}
}
// 使用模板标题,并生成包含模板关键词的 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',
@@ -1180,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,7 +3,21 @@
// 生成财务数据
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,
@@ -44,8 +58,8 @@ export const generateFinancialData = (stockCode) => {
}
},
// 资产负债表 - 嵌套结构
balanceSheet: periods.map((period, i) => ({
// 资产负债表 - 嵌套结构8期数据
balanceSheet: statementPeriods.map((period, i) => ({
period,
assets: {
current_assets: {
@@ -110,8 +124,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 利润表 - 嵌套结构
incomeStatement: periods.map((period, i) => ({
// 利润表 - 嵌套结构8期数据
incomeStatement: statementPeriods.map((period, i) => ({
period,
revenue: {
total_operating_revenue: 162350 - i * 4000,
@@ -166,8 +180,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 现金流量表 - 嵌套结构
cashflow: periods.map((period, i) => ({
// 现金流量表 - 嵌套结构8期数据
cashflow: statementPeriods.map((period, i) => ({
period,
operating_activities: {
inflow: {
@@ -193,8 +207,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 财务指标 - 嵌套结构
financialMetrics: periods.map((period, i) => ({
// 财务指标 - 嵌套结构12期数据
financialMetrics: metricsPeriods.map((period, i) => ({
period,
profitability: {
roe: 16.23 - i * 0.3,

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,
@@ -42,41 +55,77 @@ 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[] 类型
@@ -102,7 +151,7 @@ export const generateMarketData = (stockCode) => {
success: true,
data: {
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
stock_name: stockInfo.name,
latest_trade: {
close: basePrice,
change_percent: 1.89,
@@ -189,7 +238,7 @@ export const generateMarketData = (stockCode) => {
return minuteData;
})(),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
name: stockInfo.name,
trade_date: new Date().toISOString().split('T')[0],
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

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

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

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

@@ -11,7 +11,7 @@ export const lazyComponents = {
// Home 模块
// ⚡ 直接引用 HomePage无需中间层静态页面不需要骨架屏
HomePage: React.lazy(() => import('@views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
CenterDashboard: React.lazy(() => import('@views/Center')),
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
// 价值论坛 - 我的积分页面
ForumMyPoints: React.lazy(() => import('@views/Profile')),

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

@@ -292,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 };
}
);
/**
* 切换自选股状态
*/
@@ -359,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: [],
@@ -370,6 +505,9 @@ const stockSlice = createSlice({
historicalEvents: false,
chainAnalysis: false,
watchlist: false,
watchlistQuotes: false,
followingEvents: false,
eventComments: false,
allStocks: false
},
@@ -517,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;
@@ -563,6 +713,47 @@ const stockSlice = createSlice({
.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, 需要重新加载关注事件列表');
});
}
});

View File

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

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

@@ -0,0 +1,45 @@
/**
* Center - 个人中心仪表板主页面
*
* 对应路由:/home/center
* 功能:自选股监控、关注事件、投资规划等
*/
import React from 'react';
import { Box } from '@chakra-ui/react';
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
import MarketDashboard from '@views/Profile/components/MarketDashboard';
import ForumCenter from '@views/Profile/components/ForumCenter';
import { THEME } from '@views/Profile/components/MarketDashboard/constants';
/**
* CenterDashboard 组件
* 个人中心仪表板主页面
*
* 注意:右侧 WatchSidebar 已移至全局 GlobalSidebar在 MainLayout 中渲染)
*/
const CenterDashboard: React.FC = () => {
return (
<Box bg={THEME.bg.primary} minH="100vh" overflowX="hidden">
<Box px={{ base: 3, md: 4 }} py={{ base: 4, md: 6 }} maxW="container.xl" mx="auto">
{/* 市场概览仪表盘 */}
<Box mb={4}>
<MarketDashboard />
</Box>
{/* 价值论坛 / 互动中心 */}
<Box mb={4}>
<ForumCenter />
</Box>
{/* 投资规划中心(整合了日历、计划、复盘,应用 FUI 毛玻璃风格) */}
<Box>
<InvestmentPlanningCenter />
</Box>
</Box>
</Box>
);
};
export default CenterDashboard;

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"
/>

View File

@@ -0,0 +1,287 @@
/**
* FUIEventCard - 毛玻璃风格投资事件卡片组件
*
* 融合 ReviewCard 的 UI 风格(毛玻璃 + 金色主题)
* 与 EventCard 的功能(编辑、删除、展开描述)
*
* 用于复盘列表的高级视觉呈现
*/
import React, { useState, useEffect, useRef, memo } from 'react';
import {
Box,
IconButton,
Flex,
VStack,
HStack,
Text,
Tag,
TagLabel,
TagLeftIcon,
Button,
} from '@chakra-ui/react';
import {
FiEdit2,
FiTrash2,
FiCalendar,
FiTrendingUp,
FiChevronDown,
FiChevronUp,
} from 'react-icons/fi';
import { FileText, Heart, Target } from 'lucide-react';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import type { InvestmentEvent } from '@/types';
dayjs.locale('zh-cn');
// 主题颜色常量(与 ReviewCard 保持一致)
const FUI_THEME = {
bg: 'rgba(26, 26, 46, 0.7)',
border: 'rgba(212, 175, 55, 0.15)',
borderHover: 'rgba(212, 175, 55, 0.3)',
text: {
primary: 'rgba(255, 255, 255, 0.95)',
secondary: 'rgba(255, 255, 255, 0.6)',
muted: 'rgba(255, 255, 255, 0.4)',
},
accent: '#D4AF37', // 金色
icon: '#F59E0B', // 橙色图标
};
/**
* FUIEventCard Props
*/
export interface FUIEventCardProps {
/** 事件数据 */
event: InvestmentEvent;
/** 显示变体: list(列表视图) | modal(弹窗只读) */
variant?: 'list' | 'modal';
/** 主题颜色 */
colorScheme?: string;
/** 显示标签(用于 aria-label */
label?: string;
/** 编辑回调 (modal 模式不显示) */
onEdit?: (event: InvestmentEvent) => void;
/** 删除回调 (modal 模式不显示) */
onDelete?: (id: number) => void;
}
/** 描述最大显示行数 */
const MAX_LINES = 3;
/**
* 获取事件类型徽章配置
*/
const getTypeBadge = (event: InvestmentEvent) => {
if (event.source === 'future') {
return { label: '系统事件', color: '#3B82F6', bg: 'rgba(59, 130, 246, 0.15)' };
}
if (event.type === 'plan') {
return { label: '我的计划', color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)' };
}
return { label: '我的复盘', color: '#10B981', bg: 'rgba(16, 185, 129, 0.15)' };
};
/**
* FUIEventCard 组件
*/
export const FUIEventCard = memo<FUIEventCardProps>(({
event,
variant = 'list',
colorScheme = 'orange',
label = '复盘',
onEdit,
onDelete,
}) => {
const isModalVariant = variant === 'modal';
const typeBadge = getTypeBadge(event);
// 展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflow, setIsOverflow] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
// 获取描述内容
const description = event.description || event.content || '';
// 检测描述是否溢出
useEffect(() => {
const el = descriptionRef.current;
if (el && description) {
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
const maxHeight = lineHeight * MAX_LINES;
setIsOverflow(el.scrollHeight > maxHeight + 5);
} else {
setIsOverflow(false);
}
}, [description]);
return (
<Box
bg={FUI_THEME.bg}
borderRadius="lg"
p={4}
border="1px solid"
borderColor={FUI_THEME.border}
backdropFilter="blur(8px)"
transition="all 0.3s ease"
_hover={{
borderColor: FUI_THEME.borderHover,
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(212, 175, 55, 0.15)',
}}
>
<VStack align="stretch" spacing={3}>
{/* 头部区域:图标 + 标题 + 操作按钮/类型徽章 */}
<Flex justify="space-between" align="start" gap={2}>
<HStack spacing={2} flex={1}>
<Box
as={FileText}
boxSize={4}
color={FUI_THEME.icon}
flexShrink={0}
/>
<Text
fontSize="sm"
fontWeight="bold"
color={FUI_THEME.text.primary}
noOfLines={1}
>
[{event.title}]
</Text>
</HStack>
{/* modal 模式: 显示类型徽章 */}
{isModalVariant ? (
<Tag
size="sm"
bg={typeBadge.bg}
color={typeBadge.color}
border="1px solid"
borderColor={`${typeBadge.color}40`}
flexShrink={0}
>
<TagLabel fontSize="xs">{typeBadge.label}</TagLabel>
</Tag>
) : (
/* list 模式: 显示编辑/删除按钮 */
(onEdit || onDelete) && (
<HStack spacing={0}>
{onEdit && (
<IconButton
icon={<FiEdit2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: FUI_THEME.accent, bg: 'rgba(212, 175, 55, 0.1)' }}
onClick={() => onEdit(event)}
aria-label={`编辑${label}`}
/>
)}
{onDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
size="xs"
variant="ghost"
color={FUI_THEME.text.secondary}
_hover={{ color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' }}
onClick={() => onDelete(event.id)}
aria-label={`删除${label}`}
/>
)}
</HStack>
)
)}
</Flex>
{/* 日期行 */}
<HStack spacing={2}>
<FiCalendar size={12} color={FUI_THEME.text.muted} />
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
{dayjs(event.event_date || event.date).format('YYYY年MM月DD日')}
</Text>
</HStack>
{/* 描述内容(可展开/收起) */}
{description && (
<Box>
<HStack spacing={1} align="start">
<Text color={FUI_THEME.text.muted} fontSize="xs"></Text>
{event.type === 'plan' ? (
<>
<Box as={Target} boxSize={3} color={FUI_THEME.accent} flexShrink={0} mt="2px" />
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
:
</Text>
</>
) : (
<>
<Box as={Heart} boxSize={3} color="#EF4444" flexShrink={0} mt="2px" />
<Text color={FUI_THEME.text.secondary} fontSize="xs" flexShrink={0}>
:
</Text>
</>
)}
</HStack>
<Text
ref={descriptionRef}
fontSize="xs"
color={FUI_THEME.text.primary}
noOfLines={isExpanded ? undefined : MAX_LINES}
whiteSpace="pre-wrap"
mt={1}
pl={5}
>
{description}
</Text>
{isOverflow && (
<Button
size="xs"
variant="ghost"
color={FUI_THEME.accent}
mt={1}
ml={4}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
rightIcon={isExpanded ? <FiChevronUp /> : <FiChevronDown />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
{/* 股票标签 */}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap" gap={1}>
<Text fontSize="xs" color={FUI_THEME.text.secondary}>
:
</Text>
{event.stocks.map((stock, idx) => {
const stockCode = typeof stock === 'string' ? stock : stock.code;
const displayText = typeof stock === 'string' ? stock : `${stock.name}(${stock.code})`;
return (
<Tag
key={stockCode || idx}
size="sm"
bg="rgba(212, 175, 55, 0.1)"
color={FUI_THEME.accent}
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<TagLeftIcon as={FiTrendingUp} boxSize={3} />
<TagLabel fontSize="xs">{displayText}</TagLabel>
</Tag>
);
})}
</HStack>
)}
</VStack>
</Box>
);
});
FUIEventCard.displayName = 'FUIEventCard';
export default FUIEventCard;

View File

@@ -0,0 +1,285 @@
/**
* InvestmentPlanningCenter - 投资规划中心主组件 (Redux 版本)
*
* 使用 Redux 管理数据,确保列表和日历视图数据同步
*
* 组件架构:
* - InvestmentPlanningCenter (主组件)
* - CalendarPanel (日历面板,懒加载)
* - EventPanel (通用事件面板,用于计划和复盘)
* - PlanningContext (UI 状态共享)
*/
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
import {
Box,
Heading,
HStack,
Flex,
Icon,
useColorModeValue,
useToast,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Button,
ButtonGroup,
} from '@chakra-ui/react';
import {
FiCalendar,
FiFileText,
FiList,
FiPlus,
} from 'react-icons/fi';
import { Target } from 'lucide-react';
import GlassCard from '@components/GlassCard';
import { PlanningDataProvider } from './PlanningContext';
import { EventPanelSkeleton, CalendarPanelSkeleton } from './skeletons';
import type { PlanningContextValue } from '@/types';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import {
fetchAllEvents,
selectAllEvents,
selectPlanningLoading,
selectPlans,
selectReviews,
} from '@/store/slices/planningSlice';
// 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() =>
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
);
const EventPanel = lazy(() =>
import('./EventPanel').then(module => ({ default: module.EventPanel }))
);
/**
* InvestmentPlanningCenter 主组件
*/
const InvestmentPlanningCenter: React.FC = () => {
const dispatch = useAppDispatch();
const toast = useToast();
// Redux 状态
const allEvents = useAppSelector(selectAllEvents);
const loading = useAppSelector(selectPlanningLoading);
const plans = useAppSelector(selectPlans);
const reviews = useAppSelector(selectReviews);
// 颜色主题
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const cardBg = useColorModeValue('gray.50', 'gray.700');
// UI 状态
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('list');
const [listTab, setListTab] = useState<number>(0); // 0: 我的计划, 1: 我的复盘
const [openPlanModalTrigger, setOpenPlanModalTrigger] = useState<number>(0);
const [openReviewModalTrigger, setOpenReviewModalTrigger] = useState<number>(0);
// 组件挂载时加载数据
useEffect(() => {
dispatch(fetchAllEvents());
}, [dispatch]);
// 刷新数据的方法(供子组件调用)
const loadAllData = async (): Promise<void> => {
await dispatch(fetchAllEvents());
};
// 提供给子组件的 Context 值
const contextValue: PlanningContextValue = useMemo(
() => ({
allEvents,
setAllEvents: () => {}, // Redux 管理,不需要 setter
loadAllData,
loading,
setLoading: () => {}, // Redux 管理,不需要 setter
openPlanModalTrigger,
openReviewModalTrigger,
toast,
borderColor,
textColor,
secondaryText,
cardBg,
setViewMode,
setListTab,
}),
[
allEvents,
loading,
openPlanModalTrigger,
openReviewModalTrigger,
toast,
borderColor,
textColor,
secondaryText,
cardBg,
]
);
// 金色主题色
const goldAccent = 'rgba(212, 175, 55, 0.9)';
return (
<PlanningDataProvider value={contextValue}>
<GlassCard variant="transparent" cornerDecor padding="lg">
{/* 标题区域 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={2} mb={{ base: 3, md: 4 }}>
<HStack spacing={{ base: 2, md: 3 }}>
<Box
as={Target}
boxSize={{ base: 5, md: 6 }}
color={goldAccent}
/>
<Heading
size={{ base: 'sm', md: 'md' }}
bgGradient="linear(to-r, #D4AF37, #F5E6A3)"
bgClip="text"
>
</Heading>
</HStack>
{/* 视图切换按钮组 - H5隐藏 */}
<ButtonGroup size="sm" isAttached display={{ base: 'none', md: 'flex' }}>
<Button
leftIcon={<Icon as={FiList} boxSize={4} />}
bg={viewMode === 'list' ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
color={viewMode === 'list' ? goldAccent : 'rgba(255, 255, 255, 0.6)'}
border="1px solid"
borderColor={viewMode === 'list' ? 'rgba(212, 175, 55, 0.4)' : 'rgba(212, 175, 55, 0.2)'}
_hover={{ bg: 'rgba(212, 175, 55, 0.15)', color: goldAccent }}
onClick={() => setViewMode('list')}
>
</Button>
<Button
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
bg={viewMode === 'calendar' ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
color={viewMode === 'calendar' ? goldAccent : 'rgba(255, 255, 255, 0.6)'}
border="1px solid"
borderColor={viewMode === 'calendar' ? 'rgba(212, 175, 55, 0.4)' : 'rgba(212, 175, 55, 0.2)'}
_hover={{ bg: 'rgba(212, 175, 55, 0.15)', color: goldAccent }}
onClick={() => setViewMode('calendar')}
>
</Button>
</ButtonGroup>
</Flex>
{/* 渐变分割线 */}
<Box
h="1px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.4), transparent)"
mb={{ base: 3, md: 4 }}
/>
{/* 内容区域 */}
<Box>
{viewMode === 'calendar' ? (
/* 日历视图 */
<Suspense fallback={<CalendarPanelSkeleton />}>
<CalendarPanel />
</Suspense>
) : (
/* 列表视图:我的计划 / 我的复盘 切换 */
<Tabs
index={listTab}
onChange={setListTab}
variant="unstyled"
size={{ base: 'sm', md: 'md' }}
>
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
<TabList mb={0} flex="1" minW={0}>
<Tab
fontSize={{ base: '11px', md: 'sm' }}
px={{ base: 2, md: 4 }}
py={2}
whiteSpace="nowrap"
color="rgba(255, 255, 255, 0.6)"
_selected={{
color: goldAccent,
borderBottom: '2px solid',
borderColor: goldAccent,
}}
>
<Box as={Target} boxSize={{ base: 3, md: 4 }} mr={1} />
({plans.length})
</Tab>
<Tab
fontSize={{ base: '11px', md: 'sm' }}
px={{ base: 2, md: 4 }}
py={2}
whiteSpace="nowrap"
color="rgba(255, 255, 255, 0.6)"
_selected={{
color: goldAccent,
borderBottom: '2px solid',
borderColor: goldAccent,
}}
>
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
({reviews.length})
</Tab>
</TabList>
<Button
size="xs"
bg="rgba(212, 175, 55, 0.2)"
color={goldAccent}
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
leftIcon={<Icon as={FiPlus} boxSize={3} />}
fontSize={{ base: '11px', md: 'sm' }}
flexShrink={0}
_hover={{ bg: 'rgba(212, 175, 55, 0.3)' }}
onClick={() => {
if (listTab === 0) {
setOpenPlanModalTrigger(prev => prev + 1);
} else {
setOpenReviewModalTrigger(prev => prev + 1);
}
}}
>
{listTab === 0 ? '新建计划' : '新建复盘'}
</Button>
</Flex>
<TabPanels>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<EventPanelSkeleton />}>
<EventPanel
type="plan"
colorScheme="orange"
label="计划"
openModalTrigger={openPlanModalTrigger}
/>
</Suspense>
</TabPanel>
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<EventPanelSkeleton />}>
<EventPanel
type="review"
colorScheme="orange"
label="复盘"
openModalTrigger={openReviewModalTrigger}
/>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
)}
</Box>
</GlassCard>
</PlanningDataProvider>
);
};
export default InvestmentPlanningCenter;

View File

@@ -0,0 +1,96 @@
/**
* CalendarPanelSkeleton - 日历面板骨架屏组件
* 用于视图切换时的加载占位
*/
import React, { memo } from 'react';
import {
Box,
HStack,
SimpleGrid,
Skeleton,
} from '@chakra-ui/react';
// 骨架屏主题配色(黑金主题)
const SKELETON_THEME = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
/**
* CalendarPanelSkeleton 组件
* 模拟 FullCalendar 的布局结构
*/
export const CalendarPanelSkeleton: React.FC = memo(() => {
return (
<Box height={{ base: '380px', md: '560px' }}>
{/* 工具栏骨架 */}
<HStack justify="space-between" mb={4}>
{/* 左侧:导航按钮 */}
<HStack spacing={2}>
<Skeleton
height="32px"
width="32px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="32px"
width="32px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="32px"
width="60px"
borderRadius="md"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 中间:月份标题 */}
<Skeleton
height="28px"
width="150px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
{/* 右侧占位(保持对称) */}
<Box width="126px" />
</HStack>
{/* 日历网格骨架 */}
<SimpleGrid columns={7} spacing={1}>
{/* 星期头(周日 - 周六) */}
{[...Array(7)].map((_, i) => (
<Skeleton
key={`header-${i}`}
height="30px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
))}
{/* 日期格子5行 x 7列 = 35个 */}
{[...Array(35)].map((_, i) => (
<Skeleton
key={`cell-${i}`}
height="85px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
))}
</SimpleGrid>
</Box>
);
});
CalendarPanelSkeleton.displayName = 'CalendarPanelSkeleton';
export default CalendarPanelSkeleton;

View File

@@ -0,0 +1,112 @@
/**
* EventPanelSkeleton - 事件列表骨架屏组件
* 用于视图切换时的加载占位
*/
import React, { memo } from 'react';
import {
Box,
VStack,
HStack,
Skeleton,
SkeletonText,
} from '@chakra-ui/react';
// 骨架屏主题配色(黑金主题)
const SKELETON_THEME = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
cardBg: 'rgba(26, 26, 46, 0.7)',
cardBorder: 'rgba(212, 175, 55, 0.15)',
};
/**
* 单个事件卡片骨架
*/
const EventCardSkeleton: React.FC = () => (
<Box
p={4}
borderRadius="lg"
bg={SKELETON_THEME.cardBg}
border="1px solid"
borderColor={SKELETON_THEME.cardBorder}
>
{/* 头部:图标 + 标题 */}
<HStack spacing={3} mb={3}>
<Skeleton
height="16px"
width="16px"
borderRadius="sm"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="16px"
width="180px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 日期行 */}
<HStack spacing={2} mb={2}>
<Skeleton
height="12px"
width="12px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="12px"
width="120px"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
{/* 描述内容 */}
<SkeletonText
noOfLines={2}
spacing={2}
skeletonHeight={3}
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
{/* 股票标签行 */}
<HStack spacing={2} mt={3}>
<Skeleton
height="20px"
width="60px"
borderRadius="full"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
<Skeleton
height="20px"
width="80px"
borderRadius="full"
startColor={SKELETON_THEME.startColor}
endColor={SKELETON_THEME.endColor}
/>
</HStack>
</Box>
);
/**
* EventPanelSkeleton 组件
* 显示多个卡片骨架屏
*/
export const EventPanelSkeleton: React.FC = memo(() => {
return (
<VStack spacing={4} align="stretch" py={2}>
{[1, 2, 3, 4].map(i => (
<EventCardSkeleton key={i} />
))}
</VStack>
);
});
EventPanelSkeleton.displayName = 'EventPanelSkeleton';
export default EventPanelSkeleton;

View File

@@ -0,0 +1,6 @@
/**
* Center 模块骨架屏组件统一导出
*/
export { EventPanelSkeleton } from './EventPanelSkeleton';
export { CalendarPanelSkeleton } from './CalendarPanelSkeleton';

View File

@@ -0,0 +1,5 @@
/**
* Center 模块 Hooks 导出
*/
export { useCenterColors, default as useCenterColorsDefault } from './useCenterColors';

View File

@@ -0,0 +1,41 @@
/**
* useCenterColors Hook
*
* 封装 Center 模块的所有颜色变量,避免每次渲染重复调用 useColorModeValue
* 将 7 次 hook 调用合并为 1 次 useMemo 计算
*/
import { useMemo } from 'react';
import { useColorModeValue } from '@chakra-ui/react';
import type { CenterColors } from '@/types/center';
/**
* 获取 Center 模块的颜色配置
* 使用 useMemo 缓存结果,避免每次渲染重新计算
*/
export function useCenterColors(): CenterColors {
// 获取当前主题模式下的基础颜色
const textColor = useColorModeValue('gray.700', 'white');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const cardBg = useColorModeValue('white', 'gray.800');
const sectionBg = useColorModeValue('gray.50', 'gray.900');
// 使用 useMemo 缓存颜色对象,只在颜色值变化时重新创建
return useMemo(
() => ({
textColor,
borderColor,
bgColor,
hoverBg,
secondaryText,
cardBg,
sectionBg,
}),
[textColor, borderColor, bgColor, hoverBg, secondaryText, cardBg, sectionBg]
);
}
export default useCenterColors;

View File

@@ -0,0 +1,4 @@
// src/views/Center/index.js
// 入口文件,导出 Center 组件
export { default } from './Center';

View File

@@ -0,0 +1,87 @@
/**
* Center 模块格式化工具函数
*
* 这些是纯函数,提取到组件外部避免每次渲染重建
*/
/**
* 格式化相对时间(如 "5分钟前"、"3天前"
* @param dateString 日期字符串
* @returns 格式化后的相对时间字符串
*/
export function formatRelativeTime(dateString: string | null | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 1) {
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
if (diffHours < 1) {
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
return `${diffMinutes}分钟前`;
}
return `${diffHours}小时前`;
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return date.toLocaleDateString('zh-CN');
}
}
/**
* 格式化数字(如 10000 → "1w"1500 → "1.5k"
* @param num 数字
* @returns 格式化后的字符串
*/
export function formatCompactNumber(num: number | null | undefined): string {
if (!num) return '0';
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
}
/**
* 根据热度分数获取颜色
* @param score 热度分数 (0-100)
* @returns Chakra UI 颜色名称
*/
export function getHeatColor(score: number): string {
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
}
/**
* 根据涨跌幅获取颜色
* @param changePercent 涨跌幅百分比
* @returns 颜色值
*/
export function getChangeColor(changePercent: number | null | undefined): string {
if (changePercent === null || changePercent === undefined) {
return 'rgba(255, 255, 255, 0.6)';
}
if (changePercent > 0) return '#EF4444'; // 红色(涨)
if (changePercent < 0) return '#22C55E'; // 绿色(跌)
return 'rgba(255, 255, 255, 0.6)'; // 灰色(平)
}
/**
* 格式化涨跌幅显示
* @param changePercent 涨跌幅百分比
* @returns 格式化后的字符串(如 "+5.23%"
*/
export function formatChangePercent(changePercent: number | null | undefined): string {
if (changePercent === null || changePercent === undefined) {
return '--';
}
const prefix = changePercent > 0 ? '+' : '';
return `${prefix}${Number(changePercent).toFixed(2)}%`;
}

View File

@@ -0,0 +1,11 @@
/**
* Center 模块工具函数导出
*/
export {
formatRelativeTime,
formatCompactNumber,
getHeatColor,
getChangeColor,
formatChangePercent,
} from './formatters';

View File

@@ -16,12 +16,6 @@ import {
Badge,
Center,
Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useColorModeValue,
useToast,
useDisclosure,
@@ -34,7 +28,7 @@ import { useNotification } from '@contexts/NotificationContext';
import EventScrollList from './EventScrollList';
import ModeToggleButtons from './ModeToggleButtons';
import PaginationControl from './PaginationControl';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import EventDetailModal from '../EventDetailModal';
import CompactSearchBox from '../SearchFilters/CompactSearchBox';
import {
fetchDynamicNews,
@@ -113,9 +107,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
// 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...]
// 平铺模式 / 主线模式data 是数组 [...] (共用 fourRowData
const modeData = useMemo(
() => currentMode === 'four-row' ? fourRowData : verticalData,
() => (currentMode === 'four-row' || currentMode === 'mainline') ? fourRowData : verticalData,
[currentMode, fourRowData, verticalData]
);
const {
@@ -134,7 +128,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
[currentMode, data]
);
const allCachedEvents = useMemo(
() => currentMode === 'four-row' ? data : undefined,
() => (currentMode === 'four-row' || currentMode === 'mainline') ? data : undefined,
[currentMode, data]
);
@@ -249,14 +243,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
} else {
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
}
} else if (mode === 'four-row') {
// ========== 平铺模式 ==========
} else if (mode === 'four-row' || mode === 'mainline') {
// ========== 平铺模式 / 主线模式 ==========
// 检查滚动位置,只有在顶部时才刷新
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
if (scrollPos?.isNearTop) {
// 用户在顶部 10% 区域,安全刷新
console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表');
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动在顶部 → 刷新列表`);
handlePageChange(1); // 清空并刷新
toast({
title: '检测到新事件,已刷新',
@@ -266,7 +260,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
});
} else {
// 用户不在顶部,显示提示但不自动刷新
console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新');
console.log(`[DynamicNewsCard] ${mode === 'mainline' ? '主线' : '平铺'}模式 + 滚动不在顶部 → 仅提示,不刷新`);
toast({
title: '有新事件发布',
description: '滚动到顶部查看',
@@ -377,11 +371,18 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 添加防抖:如果已经初始化,不再执行
if (hasInitialized.current) return;
// ⚡ mainline 模式使用独立 API不需要通过 Redux 获取数据
if (mode === DISPLAY_MODES.MAINLINE) {
hasInitialized.current = true;
console.log('%c🚀 [初始加载] 主线模式 - 组件自己调用 /api/events/mainline', 'color: #10B981; font-weight: bold;');
return;
}
// ⚡ 始终获取最新数据,确保用户每次进入页面看到最新事件
hasInitialized.current = true;
console.log('%c🚀 [初始加载] 获取最新事件数据', 'color: #10B981; font-weight: bold;', { mode, pageSize });
dispatch(fetchDynamicNews({
mode: mode, // 传递当前模式
mode,
per_page: pageSize,
pageSize: pageSize, // 传递 pageSize 确保索引计算一致
clearCache: true,
@@ -409,11 +410,17 @@ const [currentMode, setCurrentMode] = useState('vertical');
return;
}
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', filters);
// ⚡ mainline 模式使用独立 API筛选条件变化由 MainlineTimelineView 自己监听
if (mode === DISPLAY_MODES.MAINLINE) {
console.log('%c🔍 [筛选] 主线模式 - 筛选条件变化由组件自己处理', 'color: #8B5CF6; font-weight: bold;', { filters });
return;
}
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', { filters, mode });
// 筛选条件改变时清空对应模式的缓存并从第1页开始加载
dispatch(fetchDynamicNews({
mode: mode, // 传递当前模式
mode,
per_page: pageSize,
pageSize: pageSize,
clearCache: true, // 清空缓存
@@ -436,6 +443,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 监听模式切换 - 如果新模式数据为空,请求数据
useEffect(() => {
// ⚡ mainline 模式使用独立 API不需要通过 Redux 加载数据
if (mode === DISPLAY_MODES.MAINLINE) {
console.log('%c🔄 [模式切换] 主线模式 - 由 MainlineTimelineView 组件自己加载数据', 'color: #8B5CF6; font-weight: bold;');
return;
}
const isDataEmpty = currentMode === 'vertical'
? Object.keys(allCachedEventsByPage || {}).length === 0
: (allCachedEvents?.length || 0) === 0;
@@ -443,7 +456,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
if (hasInitialized.current && isDataEmpty) {
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;');
// 🔧 根据 mode 直接计算 per_page,避免使用可能过时的 pageSize prop
// 🔧 根据 mode 直接计算 per_page
const modePageSize = mode === DISPLAY_MODES.FOUR_ROW
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
@@ -451,15 +464,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;');
dispatch(fetchDynamicNews({
mode: mode,
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
mode,
per_page: modePageSize,
pageSize: modePageSize,
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch, filters]); // 添加 filters 依赖
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
useEffect(() => {
@@ -652,6 +665,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
<EventScrollList
events={currentPageEvents}
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
filters={filters} // 筛选条件(主线模式用)
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
@@ -672,21 +686,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
</Box>
</CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxW="1600px" mx="auto" my={8}>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
)}
{/* 四排/主线模式详情弹窗 - 深色风格 */}
<EventDetailModal
open={isModalOpen}
onClose={onModalClose}
event={modalEvent}
/>
</Card>
);
});

View File

@@ -1,21 +1,20 @@
// src/views/Community/components/DynamicNews/EventScrollList.js
// 横向滚动事件列表组件
import React, { useRef, useCallback } from 'react';
import {
Box,
useColorModeValue
} from '@chakra-ui/react';
import VirtualizedFourRowGrid from './layouts/VirtualizedFourRowGrid';
import VerticalModeLayout from './layouts/VerticalModeLayout';
import React, { useRef, useCallback } from "react";
import { Box, useColorModeValue } from "@chakra-ui/react";
import VirtualizedFourRowGrid from "./layouts/VirtualizedFourRowGrid";
import MainlineTimelineView from "./layouts/MainlineTimelineView";
import VerticalModeLayout from "./layouts/VerticalModeLayout";
/**
* 事件列表组件 - 支持纵向平铺种展示模式
* 事件列表组件 - 支持纵向平铺、主线三种展示模式
* @param {Array} events - 当前页的事件列表(服务端已分页)
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
* @param {Object} filters - 筛选条件(主线模式用)
* @param {Function} loadNextPage - 加载下一页(无限滚动)
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
* @param {Function} onFourRowEventClick - 平铺/主线模式事件点击回调(打开弹窗)
* @param {Object} selectedEvent - 当前选中的事件
* @param {Function} onEventSelect - 事件选择回调
* @param {string} borderColor - 边框颜色
@@ -24,129 +23,147 @@ import VerticalModeLayout from './layouts/VerticalModeLayout';
* @param {Function} onPageChange - 页码改变回调
* @param {boolean} loading - 全局加载状态
* @param {Object} error - 错误状态
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)| 'mainline'(主线时间轴)
* @param {boolean} hasMore - 是否还有更多数据
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
* @param {Function} onToggleFollow - 关注按钮回调
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid/MainlineTimelineView 的 ref
*/
const EventScrollList = React.memo(({
events,
displayEvents,
loadNextPage,
loadPrevPage,
onFourRowEventClick,
selectedEvent,
onEventSelect,
borderColor,
currentPage,
totalPages,
onPageChange,
loading = false,
error,
mode = 'vertical',
hasMore = true,
eventFollowStatus = {},
onToggleFollow,
virtualizedGridRef
}) => {
const scrollContainerRef = useRef(null);
const EventScrollList = React.memo(
({
events,
displayEvents,
filters = {},
loadNextPage,
loadPrevPage,
onFourRowEventClick,
selectedEvent,
onEventSelect,
borderColor,
currentPage,
totalPages,
onPageChange,
loading = false,
error,
mode = "vertical",
hasMore = true,
eventFollowStatus = {},
onToggleFollow,
virtualizedGridRef,
}) => {
const scrollContainerRef = useRef(null);
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
const timelineBg = useColorModeValue('gray.50', 'gray.700');
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
const timelineBg = useColorModeValue("gray.50", "gray.700");
const timelineBorderColor = useColorModeValue("gray.400", "gray.500");
const timelineTextColor = useColorModeValue("blue.600", "blue.400");
// 滚动条颜色
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
// 滚动条颜色
const scrollbarTrackBg = useColorModeValue("#f1f1f1", "#2D3748");
const scrollbarThumbBg = useColorModeValue("#888", "#4A5568");
const scrollbarThumbHoverBg = useColorModeValue("#555", "#718096");
const getTimelineBoxStyle = () => {
return {
bg: timelineBg,
borderColor: timelineBorderColor,
borderWidth: '2px',
textColor: timelineTextColor,
boxShadow: 'sm',
const getTimelineBoxStyle = () => {
return {
bg: timelineBg,
borderColor: timelineBorderColor,
borderWidth: "2px",
textColor: timelineTextColor,
boxShadow: "sm",
};
};
};
// 重试函数
const handleRetry = useCallback(() => {
if (onPageChange) {
onPageChange(currentPage);
// 重试函数
const handleRetry = useCallback(() => {
if (onPageChange) {
onPageChange(currentPage);
}
}, [onPageChange, currentPage]);
{
/* 事件卡片容器 */
}
}, [onPageChange, currentPage]);
return (
<Box
ref={scrollContainerRef}
overflowX="hidden"
h="100%"
pt={0}
pb={4}
px={mode === "four-row" || mode === "mainline" ? 0 : { base: 0, md: 2 }}
position="relative"
data-scroll-container="true"
css={{
// 统一滚动条样式(支持横向和纵向)
"&::-webkit-scrollbar": {
width: "1px",
height: "1px",
},
"&::-webkit-scrollbar-track": {
background: scrollbarTrackBg,
borderRadius: "10px",
},
"&::-webkit-scrollbar-thumb": {
background: scrollbarThumbBg,
borderRadius: "10px",
},
"&::-webkit-scrollbar-thumb:hover": {
background: scrollbarThumbHoverBg,
},
scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
}}
>
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
<VirtualizedFourRowGrid
ref={mode === "four-row" ? virtualizedGridRef : null}
display={mode === "four-row" ? "block" : "none"}
columnsPerRow={4} // 每行显示4列
events={displayEvents || events} // 使用累积列表(如果有)
selectedEvent={selectedEvent}
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
hasMore={hasMore} // 是否还有更多数据
loading={loading} // 加载状态
error={error} // 错误状态
onRetry={handleRetry} // 重试回调
/>
{/* 事件卡片容器 */}
return (
<Box
ref={scrollContainerRef}
overflowX="hidden"
h="100%"
pt={0}
pb={4}
px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
position="relative"
data-scroll-container="true"
css={{
// 统一滚动条样式(支持横向和纵向)
'&::-webkit-scrollbar': {
width: '1px',
height: '1px',
},
'&::-webkit-scrollbar-track': {
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
}}
>
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
<VirtualizedFourRowGrid
ref={virtualizedGridRef} // ⚡ 传递 ref用于获取滚动位置
display={mode === 'four-row' ? 'block' : 'none'}
columnsPerRow={4} // 每行显示4列
events={displayEvents || events} // 使用累积列表(如果有)
selectedEvent={selectedEvent}
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
loadNextPage={loadNextPage} // 加载下一页
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
hasMore={hasMore} // 是否还有更多数据
loading={loading} // 加载状态
error={error} // 错误状态
onRetry={handleRetry} // 重试回调
/>
{/* 主线时间轴模式 - 按 lv2 概念分组,调用独立 API */}
<MainlineTimelineView
ref={mode === "mainline" ? virtualizedGridRef : null}
display={mode === "mainline" ? "block" : "none"}
filters={filters}
columnsPerRow={3}
selectedEvent={selectedEvent}
onEventSelect={onFourRowEventClick}
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
borderColor={borderColor}
/>
{/* 纵向分栏模式 */}
<VerticalModeLayout
display={mode === 'vertical' ? 'flex' : 'none'}
events={events}
selectedEvent={selectedEvent}
onEventSelect={onEventSelect}
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</Box>
);
});
{/* 纵向分栏模式 */}
<VerticalModeLayout
display={mode === "vertical" ? "flex" : "none"}
events={events}
selectedEvent={selectedEvent}
onEventSelect={onEventSelect}
eventFollowStatus={eventFollowStatus}
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</Box>
);
}
);
export default EventScrollList;

View File

@@ -6,7 +6,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
/**
* 事件列表模式切换按钮组
* @param {string} mode - 当前模式 'vertical' | 'four-row'
* @param {string} mode - 当前模式 'vertical' | 'four-row' | 'mainline'
* @param {Function} onModeChange - 模式切换回调
*/
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
@@ -20,11 +20,11 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
列表
</Button>
<Button
onClick={() => onModeChange('four-row')}
onClick={() => onModeChange('mainline')}
colorScheme="blue"
variant={mode === 'four-row' ? 'solid' : 'outline'}
variant={mode === 'mainline' ? 'solid' : 'outline'}
>
平铺
主线
</Button>
</ButtonGroup>
);

View File

@@ -29,6 +29,7 @@ export const PAGINATION_CONFIG = {
export const DISPLAY_MODES = {
FOUR_ROW: 'four-row', // 平铺网格模式
VERTICAL: 'vertical', // 纵向分栏模式
MAINLINE: 'mainline', // 主线分组模式(按 lv2 概念分组)
};
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;

View File

@@ -49,9 +49,11 @@ export const usePagination = ({
filtersRef.current = filters;
// 根据模式决定每页显示数量
// mainline 模式复用 four-row 的分页配置
const pageSize = (() => {
switch (mode) {
case DISPLAY_MODES.FOUR_ROW:
case DISPLAY_MODES.MAINLINE:
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
case DISPLAY_MODES.VERTICAL:
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
@@ -73,15 +75,15 @@ export const usePagination = ({
// 纵向模式:从页码映射获取当前页
return allCachedEventsByPage?.[currentPage] || [];
} else {
// 平铺模式:返回全部累积数据
// 平铺模式 / 主线模式:返回全部累积数据
return allCachedEvents || [];
}
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
// 当前显示的事件列表
const displayEvents = useMemo(() => {
if (mode === DISPLAY_MODES.FOUR_ROW) {
// 平铺模式:返回全部累积数据
if (mode === DISPLAY_MODES.FOUR_ROW || mode === DISPLAY_MODES.MAINLINE) {
// 平铺模式 / 主线模式:返回全部累积数据
return allCachedEvents || [];
} else {
// 纵向模式:返回当前页数据
@@ -122,8 +124,11 @@ export const usePagination = ({
filters: filtersRef.current
});
// mainline 模式使用 four-row 的 API 模式(共用同一份数据)
const apiMode = mode === DISPLAY_MODES.MAINLINE ? DISPLAY_MODES.FOUR_ROW : mode;
const result = await dispatch(fetchDynamicNews({
mode: mode, // 传递 mode 参数
mode: apiMode, // 传递 API mode 参数mainline 映射为 four-row
per_page: pageSize,
pageSize: pageSize,
clearCache: clearCache, // 传递 clearCache 参数

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
// src/views/Community/components/EventCard/MiniEventCard.js
// 迷你事件卡片组件 - 用于主线模式的紧凑显示
import React from 'react';
import {
Box,
Text,
HStack,
Tooltip,
Badge,
} from '@chakra-ui/react';
import dayjs from 'dayjs';
import { getImportanceConfig } from '@constants/importanceLevels';
// 固定深色主题颜色
const COLORS = {
cardBg: "#2d323e",
cardHoverBg: "#363c4a",
cardBorderColor: "#4a5568",
textColor: "#e2e8f0",
secondaryTextColor: "#a0aec0",
linkColor: "#63b3ed",
selectedBg: "#2c5282",
selectedBorderColor: "#4299e1",
};
/**
* 迷你事件卡片组件
* 紧凑的卡片式布局,适合在主线模式中横向排列
*/
const MiniEventCard = React.memo(({
event,
isSelected = false,
onEventClick,
}) => {
const importance = getImportanceConfig(event.importance);
// 格式化时间为简短形式
const formatTime = (timestamp) => {
const date = dayjs(timestamp);
const now = dayjs();
const diffDays = now.diff(date, 'day');
if (diffDays === 0) {
return date.format('HH:mm');
} else if (diffDays === 1) {
return '昨天 ' + date.format('HH:mm');
} else if (diffDays < 7) {
return date.format('MM-DD HH:mm');
} else {
return date.format('MM-DD');
}
};
// 获取涨跌幅显示
const getChangeDisplay = () => {
const avgChange = event.related_avg_chg;
if (avgChange == null || isNaN(Number(avgChange))) {
return null;
}
const numChange = Number(avgChange);
const isPositive = numChange > 0;
return {
value: `${isPositive ? '+' : ''}${numChange.toFixed(1)}%`,
color: isPositive ? '#fc8181' : '#68d391',
};
};
const changeDisplay = getChangeDisplay();
return (
<Tooltip
label={
<Box>
<Text fontWeight="bold" mb={1}>{event.title}</Text>
<Text fontSize="xs" color="gray.300">
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
{event.description && (
<Text fontSize="xs" mt={1} noOfLines={3}>
{event.description}
</Text>
)}
</Box>
}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={3}
borderRadius="md"
maxW="300px"
>
<Box
bg={isSelected ? COLORS.selectedBg : COLORS.cardBg}
borderWidth="1px"
borderColor={isSelected ? COLORS.selectedBorderColor : COLORS.cardBorderColor}
borderRadius="md"
px={2}
py={1.5}
cursor="pointer"
onClick={() => onEventClick?.(event)}
_hover={{
bg: isSelected ? COLORS.selectedBg : COLORS.cardHoverBg,
borderColor: importance.color || COLORS.cardBorderColor,
transform: 'translateY(-1px)',
}}
transition="all 0.15s ease"
minW="0"
>
{/* 第一行:时间 + 涨跌幅 */}
<HStack justify="space-between" spacing={1} mb={1}>
<Text
fontSize="xs"
color={COLORS.secondaryTextColor}
flexShrink={0}
>
{formatTime(event.created_at)}
</Text>
{changeDisplay && (
<Badge
fontSize="xs"
bg="transparent"
color={changeDisplay.color}
fontWeight="bold"
px={0}
>
{changeDisplay.value}
</Badge>
)}
</HStack>
{/* 第二行:标题 */}
<Text
fontSize="sm"
color={COLORS.linkColor}
fontWeight="medium"
noOfLines={2}
lineHeight="1.3"
_hover={{ textDecoration: 'underline' }}
>
{event.title}
</Text>
</Box>
</Tooltip>
);
});
MiniEventCard.displayName = 'MiniEventCard';
export default MiniEventCard;

View File

@@ -1,8 +1,64 @@
// 事件详情抽屉样式(从底部弹出)
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
.event-detail-drawer {
// 标题样式
.ant-drawer-title {
color: #1A202C;
// 事件详情抽屉样式(从底部弹出)- 深色毛玻璃风格
// 整体比背景亮一些,形成层次感
// 深色主题变量 - 提亮以区分背景
@glass-bg: #2D3748;
@glass-header-bg: #3D4A5C;
@glass-border: rgba(255, 255, 255, 0.12);
@glass-text: #F7FAFC;
@glass-text-secondary: #CBD5E0;
// 使用 :global 确保样式全局生效
:global {
// 深色抽屉样式
.event-detail-drawer-dark {
// 内容包装器 - 移除白边
.ant-drawer-content-wrapper {
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
// 内容容器
.ant-drawer-content {
background: @glass-bg !important;
border: none !important;
border-radius: 16px 16px 0 0 !important;
}
// 头部容器
.ant-drawer-header {
background: @glass-header-bg !important;
border-bottom: 1px solid @glass-border !important;
padding: 16px 24px !important;
border-radius: 16px 16px 0 0 !important;
}
// 标题样式
.ant-drawer-title {
color: @glass-text !important;
font-size: 16px !important;
font-weight: 600 !important;
line-height: 1.4 !important;
}
// 关闭按钮区域
.ant-drawer-extra {
.anticon-close {
color: @glass-text !important;
font-size: 18px;
transition: all 0.2s ease;
&:hover {
color: #fff;
transform: scale(1.1);
}
}
}
// 内容区域
.ant-drawer-body {
background: @glass-bg !important;
padding: 0 !important;
}
}
}

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Drawer } from 'antd';
import { Drawer, ConfigProvider, theme } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import './EventDetailModal.less';
interface EventDetailModalProps {
/** 是否打开弹窗 */
@@ -15,8 +14,16 @@ interface EventDetailModalProps {
event: any; // TODO: 后续可替换为具体的 Event 类型
}
// 深色主题颜色 - 比背景亮,形成层次感
const THEME = {
bg: '#2D3748',
headerBg: '#3D4A5C',
borderColor: 'rgba(255, 255, 255, 0.12)',
textColor: '#F7FAFC',
};
/**
* 事件详情抽屉组件(从底部弹出)
* 事件详情抽屉组件(从底部弹出)- 深色风格
*/
const EventDetailModal: React.FC<EventDetailModalProps> = ({
open,
@@ -26,35 +33,62 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
const isMobile = useSelector(selectIsMobile);
return (
<Drawer
open={open}
onClose={onClose}
placement="bottom"
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
width={isMobile ? '100%' : '70vw'}
title={event?.title || '事件详情'}
destroyOnHidden
rootClassName="event-detail-drawer"
closeIcon={null}
extra={
<CloseOutlined
onClick={onClose}
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
/>
}
styles={{
wrapper: isMobile ? {} : {
maxWidth: 1400,
margin: '0 auto',
borderRadius: '16px 16px 0 0',
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorBgElevated: THEME.bg,
colorBgContainer: THEME.bg,
colorText: THEME.textColor,
colorTextHeading: THEME.textColor,
colorIcon: THEME.textColor,
colorBorderSecondary: THEME.borderColor,
},
components: {
Drawer: {
colorBgElevated: THEME.bg,
colorText: THEME.textColor,
colorIcon: THEME.textColor,
colorIconHover: '#FFFFFF',
},
},
content: { borderRadius: '16px 16px 0 0' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
body: { padding: 0, background: '#FFFFFF' },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Drawer>
<Drawer
open={open}
onClose={onClose}
placement="bottom"
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
width={isMobile ? '100%' : '70vw'}
title={event?.title || '事件详情'}
destroyOnHidden
closeIcon={<CloseOutlined />}
styles={{
wrapper: isMobile ? {} : {
maxWidth: 1400,
margin: '0 auto',
},
mask: {
background: 'rgba(0, 0, 0, 0.5)',
},
content: {
borderRadius: '16px 16px 0 0',
background: THEME.bg,
},
header: {
background: THEME.headerBg,
borderBottom: `1px solid ${THEME.borderColor}`,
borderRadius: '16px 16px 0 0',
},
body: {
padding: 0,
background: THEME.bg,
},
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Drawer>
</ConfigProvider>
);
};

View File

@@ -78,3 +78,30 @@
.bracket-select .ant-select-arrow {
color: rgba(255, 255, 255, 0.65) !important;
}
/* ==================== 主线视图概念选择器 ==================== */
/* 深色主题下拉框 */
.dark-select-dropdown {
background: #252a34 !important;
border: 1px solid #3a3f4b !important;
}
.dark-select-dropdown .ant-select-item {
color: #e2e8f0 !important;
}
.dark-select-dropdown .ant-select-item:hover,
.dark-select-dropdown .ant-select-item-option-active {
background: #2d323e !important;
}
.dark-select-dropdown .ant-select-item-option-selected {
background: #3a3f4b !important;
color: #63b3ed !important;
}
.dark-select-dropdown .ant-select-item-group {
color: #a0aec0 !important;
font-size: 12px !important;
}

View File

@@ -163,13 +163,20 @@ const CompactSearchBox = ({
stockDisplayValueRef.current = null;
}
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days || filters.time_filter_key;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
let inferredKey = 'custom';
// 优先使用 time_filter_key来自 useEventFilters 的默认值)
let inferredKey = filters.time_filter_key || 'custom';
let inferredLabel = '';
if (filters.recent_days) {
if (filters.time_filter_key === 'current-trading-day') {
inferredKey = 'current-trading-day';
inferredLabel = '当前交易日';
} else if (filters.time_filter_key === 'all') {
inferredKey = 'all';
inferredLabel = '全部';
} else if (filters.recent_days) {
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
@@ -377,7 +384,12 @@ const CompactSearchBox = ({
const { range, type, label, key } = timeConfig;
let params = {};
if (type === 'recent_days') {
if (type === 'all') {
// "全部"按钮:清除所有时间限制
params.start_date = '';
params.end_date = '';
params.recent_days = '';
} else if (type === 'recent_days') {
params.recent_days = range;
params.start_date = '';
params.end_date = '';
@@ -524,90 +536,92 @@ const CompactSearchBox = ({
</div>
</Flex>
{/* 第二行:筛选条件 */}
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
</span>
}
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
disabled={industryLoading}
style={{ minWidth: isMobile ? 70 : 80 }}
suffixIcon={null}
className="transparent-cascader"
/>
{/* 事件等级 */}
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ minWidth: isMobile ? 100 : 120 }}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} />
{isMobile ? '等级' : '事件等级'}
</span>
}
maxTagCount={0}
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
className="bracket-select"
>
{IMPORTANCE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</AntSelect>
</Space>
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ minWidth: isMobile ? 55 : 120 }}
className="bracket-select"
>
{SORT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */}
{mode !== 'mainline' && (
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SortAscendingOutlined style={{ fontSize: 12 }} />
{isMobile ? opt.mobileLabel : opt.label}
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
</span>
</Option>
))}
</AntSelect>
}
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
disabled={industryLoading}
style={{ minWidth: isMobile ? 70 : 80 }}
suffixIcon={null}
className="transparent-cascader"
/>
{/* 重置按钮 */}
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
type="text"
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
>
{!isMobile && '重置筛选'}
</Button>
</Space>
</Flex>
{/* 事件等级 */}
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ minWidth: isMobile ? 100 : 120 }}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} />
{isMobile ? '等级' : '事件等级'}
</span>
}
maxTagCount={0}
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
className="bracket-select"
>
{IMPORTANCE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</AntSelect>
</Space>
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ minWidth: isMobile ? 55 : 120 }}
className="bracket-select"
>
{SORT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SortAscendingOutlined style={{ fontSize: 12 }} />
{isMobile ? opt.mobileLabel : opt.label}
</span>
</Option>
))}
</AntSelect>
{/* 重置按钮 */}
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
type="text"
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
>
{!isMobile && '重置筛选'}
</Button>
</Space>
</Flex>
)}
</div>
);
};

View File

@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { logger } from '@utils/logger';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import tradingDayUtils from '@utils/tradingDayUtils';
const { RangePicker } = DatePicker;
@@ -83,28 +84,10 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
const yesterdayEnd = now.subtract(1, 'day').endOf('day');
// 动态按钮配置(根据时段返回不同按钮数组)
// 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮
const dynamicButtonsMap = {
'pre-market': [
{
key: 'latest',
label: '最新',
range: [yesterday1500, today0930],
tooltip: '盘前资讯',
timeHint: `昨日 15:00 - 今日 09:30`,
color: 'purple',
type: 'precise'
}
],
'pre-market': [], // 盘前:使用"当前交易日"即可
'morning': [
{
key: 'latest',
label: '最新',
range: [today0930, now],
tooltip: '早盘最新',
timeHint: `今日 09:30 - ${now.format('HH:mm')}`,
color: 'green',
type: 'precise'
},
{
key: 'intraday',
label: '盘中',
@@ -115,27 +98,8 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
type: 'precise'
}
],
'lunch': [
{
key: 'latest',
label: '最新',
range: [today1130, now],
tooltip: '午休时段',
timeHint: `今日 11:30 - ${now.format('HH:mm')}`,
color: 'orange',
type: 'precise'
}
],
'lunch': [], // 午休:使用"当前交易日"即可
'afternoon': [
{
key: 'latest',
label: '最新',
range: [today1300, now],
tooltip: '午盘最新',
timeHint: `今日 13:00 - ${now.format('HH:mm')}`,
color: 'green',
type: 'precise'
},
{
key: 'intraday',
label: '盘中',
@@ -155,21 +119,35 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
type: 'precise'
}
],
'after-hours': [
{
key: 'latest',
label: '最新',
range: [today1500, now],
tooltip: '盘后最新',
timeHint: `今日 15:00 - ${now.format('HH:mm')}`,
color: 'red',
type: 'precise'
}
]
'after-hours': [] // 盘后:使用"当前交易日"即可
};
// 获取上一个交易日(使用 tdays.csv 数据)
const getPrevTradingDay = () => {
try {
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
return dayjs(prevTradingDay);
} catch (e) {
// 降级:简单地减一天(不考虑周末节假日)
logger.warn('TradingTimeFilter', '获取上一交易日失败,降级处理', e);
return now.subtract(1, 'day');
}
};
const prevTradingDay = getPrevTradingDay();
const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0);
// 固定按钮配置(始终显示)
const fixedButtons = [
{
key: 'current-trading-day',
label: '当前交易日',
range: [prevTradingDay1500, now],
tooltip: '当前交易日事件',
timeHint: `${prevTradingDay.format('MM-DD')} 15:00 - 现在`,
color: 'green',
type: 'precise'
},
{
key: 'morning-fixed',
label: '早盘',
@@ -214,6 +192,15 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
timeHint: '过去30天',
color: 'volcano',
type: 'recent_days'
},
{
key: 'all',
label: '全部',
range: null, // 无时间限制
tooltip: '显示全部事件',
timeHint: '不限时间',
color: 'default',
type: 'all'
}
];

View File

@@ -7,6 +7,8 @@ import { logger } from '../../../utils/logger';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
import { getEventDetailUrl } from '@/utils/idEncoder';
import tradingDayUtils from '@utils/tradingDayUtils';
import dayjs from 'dayjs';
/**
* 事件筛选逻辑 Hook
@@ -22,15 +24,43 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
// 筛选参数状态 - 初始化时从URL读取之后只用本地状态
const [filters, setFilters] = useState(() => {
// 计算当前交易日的默认时间范围
const getDefaultTimeRange = () => {
try {
const now = dayjs();
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
const prevTradingDay1500 = dayjs(prevTradingDay).hour(15).minute(0).second(0);
return {
start_date: prevTradingDay1500.format('YYYY-MM-DD HH:mm:ss'),
end_date: now.format('YYYY-MM-DD HH:mm:ss'),
recent_days: '', // 使用精确时间范围,不使用 recent_days
time_filter_key: 'current-trading-day' // 标记当前选中的时间按钮
};
} catch (e) {
// 降级:使用近一周
logger.warn('useEventFilters', '获取上一交易日失败,降级为近一周', e);
return {
start_date: '',
end_date: '',
recent_days: '7',
time_filter_key: 'week'
};
}
};
const defaultTimeRange = getDefaultTimeRange();
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '',
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date: searchParams.get('start_date') || '',
end_date: searchParams.get('end_date') || '',
recent_days: searchParams.get('recent_days') || '',
// 默认显示当前交易日数据上一交易日15:00 - 现在)
start_date: searchParams.get('start_date') || defaultTimeRange.start_date,
end_date: searchParams.get('end_date') || defaultTimeRange.end_date,
recent_days: searchParams.get('recent_days') || defaultTimeRange.recent_days,
time_filter_key: searchParams.get('time_filter_key') || defaultTimeRange.time_filter_key,
page: parseInt(searchParams.get('page') || '1', 10)
};
});

View File

@@ -0,0 +1,41 @@
# CompanyHeader 组件
Company 页面顶部搜索栏组件,采用 FUI 科幻风格。
## 目录结构
```
CompanyHeader/
├── index.tsx # 主组件入口
├── constants.ts # 样式常量配置
└── README.md # 本文档
```
## 功能说明
- 股票代码/名称搜索AutoComplete
- 搜索结果下拉展示
- 支持拼音缩写搜索
## 组件结构
```
CompanyHeader
└── SearchBox # 搜索框子组件
└── AutoComplete (antd) # 自动完成输入
└── Input # 搜索输入框
```
## 使用示例
```tsx
import CompanyHeader from '@views/Company/components/CompanyHeader';
<CompanyHeader onStockChange={(code) => handleStockChange(code)} />
```
## Props
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `onStockChange` | `(code: string) => void` | 是 | 股票切换回调 |

View File

@@ -85,14 +85,14 @@ const SearchBox = memo<{
return (
<Box sx={SEARCH_BOX_SX}>
<AutoComplete
popupClassName="fui-autocomplete-dropdown"
classNames={{ popup: { root: 'fui-autocomplete-dropdown' } }}
styles={{ popup: { root: DROPDOWN_STYLE } }}
value={inputCode}
options={stockOptions}
onSearch={doSearch}
onSelect={handleSelect}
onChange={setInputCode}
style={AUTOCOMPLETE_STYLE}
dropdownStyle={DROPDOWN_STYLE}
notFoundContent={isSearching ? <Spin size="small" /> : null}
>
<Input

View File

@@ -31,10 +31,14 @@ import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
/** 激活次数,变化时触发重新请求 */
activationKey?: number;
}
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
const { announcements, loading } = useAnnouncementsData(stockCode);
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isActive = true, activationKey }) => {
const { announcements, loading } = useAnnouncementsData({ stockCode, enabled: isActive, refreshKey: activationKey });
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
@@ -45,7 +49,7 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) =>
};
if (loading) {
return <LoadingState message="加载公告数据..." />;
return <LoadingState variant="skeleton" skeletonType="list" skeletonCount={5} />;
}
return (

View File

@@ -0,0 +1,271 @@
/**
* BasicInfoTab 骨架屏组件
* 用于各个 Tab 面板的加载状态显示
*/
import React from 'react';
import {
Box,
VStack,
HStack,
SimpleGrid,
Skeleton,
SkeletonText,
SkeletonCircle,
} from '@chakra-ui/react';
// 黑金主题骨架屏样式
const skeletonStyles = {
startColor: 'rgba(212, 175, 55, 0.1)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
// 卡片骨架屏样式
const cardStyle = {
bg: 'linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))',
border: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.2)',
borderRadius: '12px',
p: 4,
};
/**
* 分支机构骨架屏
*/
export const BranchesSkeleton: React.FC = () => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
<Box key={i} sx={cardStyle}>
{/* 顶部金色装饰线 */}
<Box
h="2px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.3), transparent)"
mb={4}
/>
<VStack align="start" spacing={4}>
{/* 标题行 */}
<HStack justify="space-between" w="full">
<HStack spacing={2} flex={1}>
<Skeleton
{...skeletonStyles}
height="28px"
width="28px"
borderRadius="md"
/>
<Skeleton
{...skeletonStyles}
height="16px"
width="60%"
/>
</HStack>
<Skeleton
{...skeletonStyles}
height="22px"
width="60px"
borderRadius="full"
/>
</HStack>
{/* 分隔线 */}
<Box
w="full"
h="1px"
bgGradient="linear(to-r, rgba(212, 175, 55, 0.2), transparent)"
/>
{/* 信息网格 */}
<SimpleGrid columns={2} spacing={3} w="full">
{[1, 2, 3, 4].map((j) => (
<VStack key={j} align="start" spacing={1}>
<Skeleton {...skeletonStyles} height="12px" width="50px" />
<Skeleton {...skeletonStyles} height="14px" width="80px" />
</VStack>
))}
</SimpleGrid>
</VStack>
</Box>
))}
</SimpleGrid>
);
/**
* 工商信息骨架屏
*/
export const BusinessInfoSkeleton: React.FC = () => (
<VStack spacing={4} align="stretch">
{/* 上半部分:工商信息 + 服务机构 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
{/* 工商信息卡片 */}
<Box sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2, 3, 4].map((i) => (
<HStack key={i} spacing={3} p={2}>
<Skeleton {...skeletonStyles} height="14px" width="14px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
<Skeleton {...skeletonStyles} height="14px" flex={1} />
</HStack>
))}
</VStack>
</Box>
{/* 服务机构卡片 */}
<Box sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2].map((i) => (
<Box key={i} p={4} borderRadius="10px" bg="rgba(255,255,255,0.02)">
<HStack spacing={2} mb={2}>
<Skeleton {...skeletonStyles} height="14px" width="14px" />
<Skeleton {...skeletonStyles} height="12px" width="80px" />
</HStack>
<Skeleton {...skeletonStyles} height="14px" width="70%" />
</Box>
))}
</VStack>
</Box>
</SimpleGrid>
{/* 下半部分:主营业务 + 经营范围 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<SkeletonText
{...skeletonStyles}
noOfLines={4}
spacing={3}
/>
</Box>
))}
</SimpleGrid>
</VStack>
);
/**
* 股权结构骨架屏
*/
export const ShareholderSkeleton: React.FC = () => (
<Box p={4}>
<VStack spacing={6} align="stretch">
{/* 实际控制人 + 股权集中度 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="18px" width="18px" />
<Skeleton {...skeletonStyles} height="18px" width="100px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2, 3].map((j) => (
<HStack key={j} justify="space-between">
<Skeleton {...skeletonStyles} height="14px" width="80px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
</HStack>
))}
</VStack>
</Box>
))}
</SimpleGrid>
{/* 十大股东表格 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="18px" width="18px" />
<Skeleton {...skeletonStyles} height="18px" width="100px" />
</HStack>
<VStack spacing={2} align="stretch">
{/* 表头 */}
<HStack spacing={4} pb={2} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.1)">
<Skeleton {...skeletonStyles} height="12px" width="30px" />
<Skeleton {...skeletonStyles} height="12px" flex={1} />
<Skeleton {...skeletonStyles} height="12px" width="60px" />
<Skeleton {...skeletonStyles} height="12px" width="60px" />
</HStack>
{/* 表格行 */}
{[1, 2, 3, 4, 5].map((j) => (
<HStack key={j} spacing={4} py={2}>
<SkeletonCircle {...skeletonStyles} size="6" />
<Skeleton {...skeletonStyles} height="14px" flex={1} />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
</HStack>
))}
</VStack>
</Box>
))}
</SimpleGrid>
</VStack>
</Box>
);
/**
* 管理团队骨架屏
*/
export const ManagementSkeleton: React.FC = () => (
<Box p={4}>
<VStack spacing={6} align="stretch">
{/* 每个分类 */}
{[1, 2, 3].map((i) => (
<Box key={i}>
{/* 分类标题 */}
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="20px" width="20px" />
<Skeleton {...skeletonStyles} height="18px" width="80px" />
<Skeleton
{...skeletonStyles}
height="20px"
width="30px"
borderRadius="full"
/>
</HStack>
{/* 人员卡片网格 */}
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{[1, 2, 3, 4].map((j) => (
<Box key={j} sx={cardStyle}>
<VStack spacing={3}>
<SkeletonCircle {...skeletonStyles} size="12" />
<Skeleton {...skeletonStyles} height="16px" width="60px" />
<Skeleton {...skeletonStyles} height="12px" width="80px" />
<HStack spacing={2}>
<Skeleton {...skeletonStyles} height="10px" width="40px" />
<Skeleton {...skeletonStyles} height="10px" width="40px" />
</HStack>
</VStack>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
</Box>
);
/**
* 通用内容骨架屏
*/
export const ContentSkeleton: React.FC = () => (
<Box p={4}>
<SkeletonText {...skeletonStyles} noOfLines={6} spacing={4} />
</Box>
);
export default {
BranchesSkeleton,
BusinessInfoSkeleton,
ShareholderSkeleton,
ManagementSkeleton,
ContentSkeleton,
};

View File

@@ -16,10 +16,12 @@ import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons
import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
import { BranchesSkeleton } from "./BasicInfoTabSkeleton";
interface BranchesPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
// 黑金卡片样式
@@ -65,11 +67,11 @@ const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label,
</VStack>
);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
if (loading) {
return <LoadingState message="加载分支机构数据..." />;
return <BranchesSkeleton />;
}
if (branches.length === 0) {

View File

@@ -10,7 +10,6 @@ import {
SimpleGrid,
Center,
Icon,
Spinner,
} from "@chakra-ui/react";
import {
FaBuilding,
@@ -27,9 +26,12 @@ import {
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo";
import { BusinessInfoSkeleton } from "./BasicInfoTabSkeleton";
interface BusinessInfoPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
// 区块标题组件
@@ -150,15 +152,11 @@ const TextSection: React.FC<{
</Box>
);
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
const { basicInfo, loading } = useBasicInfo(stockCode);
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode, isActive = true }) => {
const { basicInfo, loading } = useBasicInfo({ stockCode, enabled: isActive });
if (loading) {
return (
<Center h="200px">
<Spinner size="lg" color={THEME.gold} />
</Center>
);
return <BusinessInfoSkeleton />;
}
if (!basicInfo) {

View File

@@ -0,0 +1,197 @@
/**
* 公司概览 - 导航骨架屏组件
*
* 用于懒加载时显示,让二级导航立即可见
* 导航使用真实 UI内容区域显示骨架屏
*/
import React from 'react';
import {
Box,
Flex,
HStack,
Text,
Icon,
Skeleton,
VStack,
Card,
CardBody,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
} from '@chakra-ui/react';
import {
FaShareAlt,
FaUserTie,
FaSitemap,
FaInfoCircle,
} from 'react-icons/fa';
// 深空 FUI 主题配置(与 SubTabContainer 保持一致)
const DEEP_SPACE = {
bgGlass: 'rgba(12, 14, 28, 0.6)',
borderGold: 'rgba(212, 175, 55, 0.2)',
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
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)',
textDark: '#0A0A14',
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
radius: '12px',
radiusLG: '16px',
};
// 导航配置(与主组件 config.ts 保持同步)
const OVERVIEW_TABS = [
{ key: 'shareholder', name: '股权结构', icon: FaShareAlt },
{ key: 'management', name: '管理团队', icon: FaUserTie },
{ key: 'branches', name: '分支机构', icon: FaSitemap },
{ key: 'business', name: '工商信息', icon: FaInfoCircle },
];
/**
* 股权结构内容骨架屏
*/
const ShareholderContentSkeleton: React.FC = () => (
<Box p={4}>
{/* 表格骨架屏 */}
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody p={0}>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="70px" startColor="gray.700" endColor="gray.600" />
</Th>
</Tr>
</Thead>
<Tbody>
{[1, 2, 3, 4, 5].map((i) => (
<Tr key={i}>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="120px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
</Box>
);
/**
* CompanyOverview 导航骨架屏
*
* 显示真实的导航 Tab默认选中第一个内容区域显示骨架屏
*/
const CompanyOverviewNavSkeleton: React.FC = () => {
return (
<Box>
{/* 导航栏容器 - compact 模式(无外边距) */}
<Flex
bg={DEEP_SPACE.bgGlass}
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor={DEEP_SPACE.borderGold}
borderRadius={0}
mx={0}
mb={0}
position="relative"
boxShadow="none"
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',
}}
>
<HStack
border="none"
px={3}
py={2}
flexWrap="nowrap"
gap={1.5}
>
{OVERVIEW_TABS.map((tab, idx) => {
const isSelected = idx === 0;
return (
<Box
key={tab.key}
color={isSelected ? DEEP_SPACE.textDark : DEEP_SPACE.textWhite}
borderRadius={DEEP_SPACE.radius}
px={4}
py={2}
fontSize="13px"
fontWeight={isSelected ? '700' : '500'}
whiteSpace="nowrap"
flexShrink={0}
border="1px solid"
borderColor={isSelected ? DEEP_SPACE.borderGoldHover : 'transparent'}
position="relative"
letterSpacing="0.03em"
bg={isSelected ? DEEP_SPACE.selectedBg : 'transparent'}
boxShadow={isSelected ? DEEP_SPACE.glowGold : 'none'}
transform={isSelected ? 'translateY(-2px)' : 'none'}
cursor="default"
>
<HStack spacing={1.5}>
<Icon
as={tab.icon}
boxSize={3.5}
opacity={isSelected ? 1 : 0.7}
/>
<Text>{tab.name}</Text>
</HStack>
</Box>
);
})}
</HStack>
</Box>
</Flex>
{/* 内容区域骨架屏 */}
<ShareholderContentSkeleton />
</Box>
);
};
export default CompanyOverviewNavSkeleton;

View File

@@ -19,13 +19,15 @@ import LoadingState from "./LoadingState";
interface DisclosureSchedulePanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode, isActive = true }) => {
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
if (loading) {
return <LoadingState message="加载披露日程..." />;
return <LoadingState variant="skeleton" skeletonType="grid" skeletonCount={4} />;
}
if (disclosureSchedule.length === 0) {

View File

@@ -1,22 +1,110 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
// 复用的加载状态组件
// 复用的加载状态组件 - 支持骨架屏
import React from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
import React, { memo } from "react";
import {
Center,
VStack,
Spinner,
Text,
Box,
Skeleton,
SimpleGrid,
HStack,
} from "@chakra-ui/react";
import { THEME } from "../config";
// 骨架屏颜色配置
const SKELETON_COLORS = {
startColor: "rgba(26, 32, 44, 0.6)",
endColor: "rgba(212, 175, 55, 0.2)",
};
interface LoadingStateProps {
message?: string;
height?: string;
/** 使用骨架屏模式(更好的视觉体验) */
variant?: "spinner" | "skeleton";
/** 骨架屏类型grid网格布局或 list列表布局 */
skeletonType?: "grid" | "list";
/** 骨架屏项目数量 */
skeletonCount?: number;
}
/**
* 加载状态组件(黑金主题
* 网格骨架屏(用于披露日程等
*/
const LoadingState: React.FC<LoadingStateProps> = ({
const GridSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{Array.from({ length: count }).map((_, i) => (
<Skeleton
key={i}
height="80px"
borderRadius="md"
{...SKELETON_COLORS}
/>
))}
</SimpleGrid>
));
GridSkeleton.displayName = "GridSkeleton";
/**
* 列表骨架屏(用于公告列表等)
*/
const ListSkeleton: React.FC<{ count: number }> = memo(({ count }) => (
<VStack spacing={2} align="stretch">
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
p={3}
borderRadius="md"
bg="rgba(26, 32, 44, 0.4)"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<HStack justify="space-between">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Skeleton height="20px" width="60px" borderRadius="sm" {...SKELETON_COLORS} />
<Skeleton height="16px" width="80px" borderRadius="sm" {...SKELETON_COLORS} />
</HStack>
<Skeleton height="18px" width="90%" borderRadius="sm" {...SKELETON_COLORS} />
</VStack>
<Skeleton height="32px" width="32px" borderRadius="md" {...SKELETON_COLORS} />
</HStack>
</Box>
))}
</VStack>
));
ListSkeleton.displayName = "ListSkeleton";
/**
* 加载状态组件(黑金主题)
*
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
* @param skeletonType - 骨架屏类型:"grid" 或 "list"
*/
const LoadingState: React.FC<LoadingStateProps> = memo(({
message = "加载中...",
height = "200px",
variant = "spinner",
skeletonType = "list",
skeletonCount = 4,
}) => {
if (variant === "skeleton") {
return (
<Box minH={height} p={4}>
{skeletonType === "grid" ? (
<GridSkeleton count={skeletonCount} />
) : (
<ListSkeleton count={skeletonCount} />
)}
</Box>
);
}
return (
<Center h={height}>
<VStack>
@@ -27,6 +115,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
</VStack>
</Center>
);
};
});
LoadingState.displayName = "LoadingState";
export default LoadingState;

View File

@@ -11,9 +11,12 @@ import {
ShareholdersTable,
} from "../../components/shareholder";
import TabPanelContainer from "@components/TabPanelContainer";
import { ShareholderSkeleton } from "./BasicInfoTabSkeleton";
interface ShareholderPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
/**
@@ -23,17 +26,17 @@ interface ShareholderPanelProps {
* - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode, isActive = true }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
} = useShareholderData({ stockCode, enabled: isActive });
return (
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
<TabPanelContainer loading={loading} skeleton={<ShareholderSkeleton />}>
{/* 实际控制人 + 股权集中度 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>

View File

@@ -9,3 +9,6 @@ export { ManagementPanel } from "./management";
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
export { default as BranchesPanel } from "./BranchesPanel";
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
// 骨架屏组件
export * from "./BasicInfoTabSkeleton";

View File

@@ -13,6 +13,7 @@ import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config";
import TabPanelContainer from "@components/TabPanelContainer";
import CategorySection from "./CategorySection";
import { ManagementSkeleton } from "../BasicInfoTabSkeleton";
import type {
ManagementPerson,
ManagementCategory,
@@ -22,6 +23,8 @@ import type {
interface ManagementPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
/**
@@ -68,8 +71,8 @@ const categorizeManagement = (management: ManagementPerson[]): CategorizedManage
return categories;
};
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
const { management, loading } = useManagementData(stockCode);
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode, isActive = true }) => {
const { management, loading } = useManagementData({ stockCode, enabled: isActive });
// 使用 useMemo 缓存分类计算结果
const categorizedManagement = useMemo(
@@ -78,7 +81,7 @@ const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
);
return (
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
<TabPanelContainer loading={loading} skeleton={<ManagementSkeleton />}>
{CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category];
const people = categorizedManagement[category];

View File

@@ -2,6 +2,7 @@
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
@@ -65,15 +66,18 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return (
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"
compact
contentPadding={0}
/>
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"
size="sm"
/>
</CardBody>
</Card>
);
};

View File

@@ -75,8 +75,9 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
onTabChange={onTabChange}
componentProps={{}}
themePreset="blackGold"
size="sm"
/>
<LoadingState message="加载数据中..." height="200px" />
<LoadingState variant="skeleton" height="300px" skeletonRows={6} />
</CardBody>
</Card>
);
@@ -99,6 +100,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
onToggleSegment,
}}
themePreset="blackGold"
size="sm"
/>
</CardBody>
</Card>

View File

@@ -1,663 +0,0 @@
// src/views/Company/components/CompanyOverview/NewsEventsTab.js
// 新闻动态 Tab - 相关新闻事件列表 + 分页
import React from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
Button,
Input,
InputGroup,
InputLeftElement,
Tag,
Center,
Spinner,
} from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import {
FaNewspaper,
FaBullhorn,
FaGavel,
FaFlask,
FaDollarSign,
FaShieldAlt,
FaFileAlt,
FaIndustry,
FaEye,
FaFire,
FaChartLine,
FaChevronLeft,
FaChevronRight,
} from "react-icons/fa";
import { getEventDetailUrl } from "@/utils/idEncoder";
// 黑金主题配色(文字使用更亮的金色提高对比度)
const THEME_PRESETS = {
blackGold: {
bg: "#0A0E17",
cardBg: "#1A1F2E",
cardHoverBg: "#212633",
cardBorder: "rgba(212, 175, 55, 0.2)",
cardHoverBorder: "#F4D03F", // 亮金色
textPrimary: "#E8E9ED",
textSecondary: "#A0A4B8",
textMuted: "#6B7280",
gold: "#F4D03F", // 亮金色(用于文字)
goldLight: "#FFD54F",
inputBg: "#151922",
inputBorder: "#2D3748",
buttonBg: "#D4AF37", // 按钮背景保持深金色
buttonText: "#0A0E17",
buttonHoverBg: "#FFD54F",
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
tagBg: "rgba(212, 175, 55, 0.15)",
tagColor: "#F4D03F", // 亮金色
spinnerColor: "#F4D03F", // 亮金色
},
default: {
bg: "white",
cardBg: "white",
cardHoverBg: "gray.50",
cardBorder: "gray.200",
cardHoverBorder: "blue.300",
textPrimary: "gray.800",
textSecondary: "gray.600",
textMuted: "gray.500",
gold: "blue.500",
goldLight: "blue.400",
inputBg: "white",
inputBorder: "gray.200",
buttonBg: "blue.500",
buttonText: "white",
buttonHoverBg: "blue.600",
badgeS: { bg: "red.100", color: "red.600" },
badgeA: { bg: "orange.100", color: "orange.600" },
badgeB: { bg: "yellow.100", color: "yellow.600" },
badgeC: { bg: "green.100", color: "green.600" },
tagBg: "cyan.50",
tagColor: "cyan.600",
spinnerColor: "blue.500",
},
};
/**
* 新闻动态 Tab 组件
*
* Props:
* - newsEvents: 新闻事件列表数组
* - newsLoading: 加载状态
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
* - searchQuery: 搜索关键词
* - onSearchChange: 搜索输入回调 (value) => void
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
* - themePreset: 主题预设 'blackGold' | 'default'
*/
const NewsEventsTab = ({
newsEvents = [],
newsLoading = false,
newsPagination = {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
},
searchQuery = "",
onSearchChange,
onSearch,
onPageChange,
cardBg,
themePreset = "default",
}) => {
const navigate = useNavigate();
// 获取主题配色
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
const isBlackGold = themePreset === "blackGold";
// 点击事件卡片,跳转到详情页
const handleEventClick = (eventId) => {
if (eventId) {
navigate(getEventDetailUrl(eventId));
}
};
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
企业公告: FaBullhorn,
政策: FaGavel,
技术突破: FaFlask,
企业融资: FaDollarSign,
政策监管: FaShieldAlt,
政策动态: FaFileAlt,
行业事件: FaIndustry,
};
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射 - 根据主题返回不同配色
const getImportanceBadgeStyle = (importance) => {
if (isBlackGold) {
const styles = {
S: theme.badgeS,
A: theme.badgeA,
B: theme.badgeB,
C: theme.badgeC,
};
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
}
// 默认主题使用 colorScheme
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return { colorScheme: colorMap[importance] || "gray" };
};
// 处理搜索输入
const handleInputChange = (e) => {
onSearchChange?.(e.target.value);
};
// 处理搜索提交
const handleSearchSubmit = () => {
onSearch?.();
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSearchSubmit();
}
};
// 处理分页
const handlePageChange = (page) => {
onPageChange?.(page);
// 滚动到列表顶部
document
.getElementById("news-list-top")
?.scrollIntoView({ behavior: "smooth" });
};
// 渲染分页按钮
const renderPaginationButtons = () => {
const { page: currentPage, pages: totalPages } = newsPagination;
const pageButtons = [];
// 显示当前页及前后各2页
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
// 如果开始页大于1显示省略号
if (startPage > 1) {
pageButtons.push(
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
const isActive = i === currentPage;
pageButtons.push(
<Button
key={i}
size="sm"
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
color={isActive ? theme.buttonText : theme.textSecondary}
borderColor={isActive ? theme.gold : theme.cardBorder}
borderWidth="1px"
_hover={{
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
borderColor: theme.gold
}}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
{i}
</Button>
);
}
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
return pageButtons;
};
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color={theme.textMuted} />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
bg={theme.inputBg}
borderColor={theme.inputBorder}
color={theme.textPrimary}
_placeholder={{ color: theme.textMuted }}
_hover={{ borderColor: theme.gold }}
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
/>
</InputGroup>
<Button
bg={theme.buttonBg}
color={theme.buttonText}
_hover={{ bg: theme.buttonHoverBg }}
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
>
搜索
</Button>
</HStack>
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color={theme.gold} />
<Text fontSize="sm" color={theme.textSecondary}>
共找到{" "}
<Text as="span" fontWeight="bold" color={theme.gold}>
{newsPagination.total}
</Text>{" "}
条新闻
</Text>
</HStack>
)}
</HStack>
<div id="news-list-top" />
{/* 新闻列表 */}
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
<Text color={theme.textSecondary}>正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceBadgeStyle = getImportanceBadgeStyle(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
return (
<Card
key={event.id || idx}
variant="outline"
bg={theme.cardBg}
borderColor={theme.cardBorder}
cursor="pointer"
onClick={() => handleEventClick(event.id)}
_hover={{
bg: theme.cardHoverBg,
shadow: "md",
borderColor: theme.cardHoverBorder,
}}
transition="all 0.2s"
>
<CardBody p={4}>
<VStack align="stretch" spacing={3}>
{/* 标题栏 */}
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon
as={eventTypeIcon}
color={theme.gold}
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
color={theme.textPrimary}
>
{event.title}
</Text>
</HStack>
{/* 标签栏 */}
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
color={isBlackGold ? importanceBadgeStyle.color : undefined}
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
color={isBlackGold ? "#60A5FA" : undefined}
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
>
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
color={isBlackGold ? "#A78BFA" : undefined}
>
投资分: {event.invest_score}
</Badge>
)}
{event.keywords && event.keywords.length > 0 && (
<>
{event.keywords
.slice(0, 4)
.map((keyword, kidx) => (
<Tag
key={kidx}
size="sm"
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
bg={isBlackGold ? theme.tagBg : undefined}
color={isBlackGold ? theme.tagColor : undefined}
>
{typeof keyword === "string"
? keyword
: keyword?.concept ||
keyword?.name ||
"未知"}
</Tag>
))}
</>
)}
</HStack>
</VStack>
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color={theme.textMuted}>
{event.created_at
? new Date(
event.created_at
).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
: ""}
</Text>
<HStack spacing={3}>
{event.view_count !== undefined && (
<HStack spacing={1}>
<Icon
as={FaEye}
boxSize={3}
color={theme.textMuted}
/>
<Text fontSize="xs" color={theme.textMuted}>
{event.view_count}
</Text>
</HStack>
)}
{event.hot_score !== undefined && (
<HStack spacing={1}>
<Icon
as={FaFire}
boxSize={3}
color={theme.goldLight}
/>
<Text fontSize="xs" color={theme.textMuted}>
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color={theme.textMuted}>
@{event.creator.username}
</Text>
)}
</VStack>
</HStack>
{/* 描述 */}
{event.description && (
<Text
fontSize="sm"
color={theme.textSecondary}
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 收益率数据 */}
{(event.related_avg_chg !== null ||
event.related_max_chg !== null ||
event.related_week_chg !== null) && (
<Box
pt={2}
borderTop="1px"
borderColor={theme.cardBorder}
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color={theme.textMuted}
/>
<Text
fontSize="xs"
color={theme.textMuted}
fontWeight="medium"
>
相关涨跌:
</Text>
</HStack>
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
平均
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_avg_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_avg_chg > 0 ? "+" : ""}
{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_max_chg !== null &&
event.related_max_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
最大
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_max_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_max_chg > 0 ? "+" : ""}
{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
)}
{event.related_week_chg !== null &&
event.related_week_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color={theme.textMuted}>
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_week_chg > 0
? "#EF4444"
: "#10B981"
}
>
{event.related_week_chg > 0
? "+"
: ""}
{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
)}
</HStack>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</VStack>
{/* 分页控件 */}
{newsPagination.pages > 1 && (
<Box pt={4}>
<HStack
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 分页信息 */}
<Text fontSize="sm" color={theme.textSecondary}>
{newsPagination.page} / {newsPagination.pages}
</Text>
{/* 分页按钮 */}
<HStack spacing={2}>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
>
首页
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
isDisabled={!newsPagination.has_prev || newsLoading}
>
上一页
</Button>
{/* 页码按钮 */}
{renderPaginationButtons()}
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
isDisabled={!newsPagination.has_next || newsLoading}
>
下一页
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(newsPagination.pages)}
isDisabled={!newsPagination.has_next || newsLoading}
rightIcon={<Icon as={FaChevronRight} />}
>
末页
</Button>
</HStack>
</HStack>
</Box>
)}
</>
) : (
<Center h="400px">
<VStack spacing={3}>
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color={theme.textMuted}>
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>
</Center>
)}
</VStack>
</CardBody>
</Card>
</VStack>
);
};
export default NewsEventsTab;

View File

@@ -0,0 +1,105 @@
# CompanyOverview 组件
公司概览模块,包含基本信息和深度分析两个主要 Tab。
## 目录结构
```
CompanyOverview/
├── index.tsx # 主组件入口
├── types.ts # 类型定义
├── utils.ts # 工具函数
├── README.md # 本文档
├── hooks/ # 数据获取 Hooks
│ ├── useBasicInfo.ts # 公司基本信息
│ ├── useShareholderData.ts # 股东数据
│ ├── useManagementData.ts # 管理层数据
│ ├── useBranchesData.ts # 分支机构数据
│ ├── useAnnouncementsData.ts # 公告数据
│ └── useDisclosureData.ts # 披露日程数据
├── BasicInfoTab/ # 基本信息 Tab
│ ├── index.tsx # Tab 入口
│ ├── config.ts # 配置
│ ├── utils.ts # 工具函数
│ └── components/ # 子组件
│ ├── BusinessInfoPanel.tsx # 工商信息面板
│ ├── ShareholderPanel.tsx # 股东信息面板
│ ├── AnnouncementsPanel.tsx # 公告面板
│ ├── BranchesPanel.tsx # 分支机构面板
│ ├── DisclosureSchedulePanel.tsx # 披露日程面板
│ ├── LoadingState.tsx # 加载状态
│ └── management/ # 管理层组件
│ ├── ManagementPanel.tsx # 管理层面板
│ ├── ManagementCard.tsx # 管理层卡片
│ ├── CategorySection.tsx # 分类区块
│ └── types.ts # 类型定义
├── DeepAnalysisTab/ # 深度分析 Tab
│ ├── index.tsx # Tab 入口
│ ├── types.ts # 类型定义
│ ├── atoms/ # 原子组件
│ │ ├── ScoreBar.tsx # 评分条
│ │ ├── KeyFactorCard.tsx # 关键因素卡片
│ │ ├── BusinessTreeItem.tsx # 业务树节点
│ │ ├── ProcessNavigation.tsx # 流程导航
│ │ ├── ValueChainFilterBar.tsx # 产业链筛选栏
│ │ └── DisclaimerBox.tsx # 免责声明
│ ├── components/ # 分子组件
│ │ ├── CorePositioningCard/ # 核心定位卡片
│ │ ├── BusinessStructureCard.tsx # 业务结构
│ │ ├── BusinessSegmentsCard.tsx # 业务板块
│ │ ├── CompetitiveAnalysisCard.tsx # 竞争分析
│ │ ├── StrategyAnalysisCard.tsx # 战略分析
│ │ ├── KeyFactorsCard.tsx # 关键因素
│ │ ├── TimelineCard.tsx # 时间线
│ │ └── ValueChainCard.tsx # 产业链
│ ├── organisms/ # 有机体组件
│ │ ├── TimelineComponent/ # 时间线组件
│ │ └── ValueChainNodeCard/ # 产业链节点
│ ├── tabs/ # 子 Tab
│ │ ├── BusinessTab.tsx # 业务分析
│ │ ├── StrategyTab.tsx # 战略分析
│ │ ├── ValueChainTab.tsx # 产业链分析
│ │ └── DevelopmentTab.tsx # 发展历程
│ └── utils/
│ └── chartOptions.ts # 图表配置
└── components/ # 共享组件
└── shareholder/ # 股东相关
├── ShareholdersTable.tsx # 股东表格
├── ConcentrationCard.tsx # 股权集中度
└── ActualControlCard.tsx # 实际控制人
```
## 组件层级
```
CompanyOverview
├── SubTabContainer # Tab 容器
│ ├── BasicInfoTab # 基本信息
│ │ ├── BusinessInfoPanel
│ │ ├── ShareholderPanel
│ │ │ ├── ShareholdersTable
│ │ │ ├── ConcentrationCard
│ │ │ └── ActualControlCard
│ │ ├── ManagementPanel
│ │ ├── AnnouncementsPanel
│ │ ├── BranchesPanel
│ │ └── DisclosureSchedulePanel
│ │
│ └── DeepAnalysisTab # 深度分析
│ ├── BusinessTab
│ ├── StrategyTab
│ ├── ValueChainTab
│ └── DevelopmentTab
```
## 使用示例
```tsx
import CompanyOverview from '@views/Company/components/CompanyOverview';
<CompanyOverview stockCode="600000" />
```

View File

@@ -215,7 +215,7 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
<Table
columns={columns}
dataSource={shareholders.slice(0, 10)}
rowKey={(record: Shareholder, index?: number) => `${record.shareholder_name}-${index}`}
rowKey={(record: Shareholder) => `${record.shareholder_name}-${record.shareholder_rank ?? ''}-${record.end_date ?? ''}`}
pagination={false}
size={isMobile ? "small" : "middle"}
scroll={{ x: isMobile ? 400 : undefined }}

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Announcement } from "../types";
@@ -11,6 +11,15 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseAnnouncementsDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
/** 刷新标识,变化时触发重新请求 */
refreshKey?: number;
}
interface UseAnnouncementsDataResult {
announcements: Announcement[];
loading: boolean;
@@ -18,16 +27,50 @@ interface UseAnnouncementsDataResult {
}
/**
* 公告数据 Hook
* @param stockCode - 股票代码
* 公告数据 Hook(支持延迟加载和刷新)
* @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
* @param options.refreshKey - 刷新标识,变化时触发重新请求
*/
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => {
export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseAnnouncementsDataResult => {
const { stockCode, enabled = true, refreshKey } = options;
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCode 和 refreshKey
const lastStockCodeRef = useRef<string | undefined>(undefined);
const lastRefreshKeyRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) {
setLoading(false);
return;
}
// stockCode 或 refreshKey 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode || lastRefreshKeyRef.current !== refreshKey) {
// refreshKey 变化时强制重新加载
if (lastRefreshKeyRef.current !== refreshKey && lastRefreshKeyRef.current !== undefined) {
hasLoadedRef.current = false;
}
// stockCode 变化时重置
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
}
lastStockCodeRef.current = stockCode;
lastRefreshKeyRef.current = refreshKey;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController();
@@ -46,18 +89,25 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
} else {
setError("加载公告数据失败");
}
setLoading(false);
hasLoadedRef.current = true;
} catch (err: any) {
if (err.name === "CanceledError") return;
// 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
hasLoadedRef.current = true;
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled, refreshKey]);
return { announcements, loading, error };
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { announcements, loading: isLoading, error };
};

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseBasicInfoOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseBasicInfoResult {
basicInfo: BasicInfo | null;
loading: boolean;
@@ -18,16 +25,25 @@ interface UseBasicInfoResult {
}
/**
* 公司基本信息 Hook
* @param stockCode - 股票代码
* 公司基本信息 Hook(支持延迟加载)
* @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
export const useBasicInfo = (options: UseBasicInfoOptions): UseBasicInfoResult => {
const { stockCode, enabled = true } = options;
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) {
setLoading(false);
return;
}
const controller = new AbortController();
@@ -46,18 +62,25 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
} else {
setError("加载基本信息失败");
}
setLoading(false);
setHasLoaded(true);
} catch (err: any) {
if (err.name === "CanceledError") return;
// 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
setHasLoaded(true);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled]);
return { basicInfo, loading, error };
const isLoading = loading || (enabled && !hasLoaded && !error);
return { basicInfo, loading: isLoading, error };
};

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Branch } from "../types";
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseBranchesDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseBranchesDataResult {
branches: Branch[];
loading: boolean;
@@ -18,16 +25,39 @@ interface UseBranchesDataResult {
}
/**
* 分支机构数据 Hook
* @param stockCode - 股票代码
* 分支机构数据 Hook(支持延迟加载)
* @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDataResult => {
const { stockCode, enabled = true } = options;
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!stockCode) return;
if (!enabled || !stockCode) {
setLoading(false);
return;
}
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController();
@@ -46,18 +76,25 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
} else {
setError("加载分支机构数据失败");
}
setLoading(false);
hasLoadedRef.current = true;
} catch (err: any) {
if (err.name === "CanceledError") return;
// 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
hasLoadedRef.current = true;
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled]);
return { branches, loading, error };
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { branches, loading: isLoading, error };
};

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types";
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseDisclosureDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseDisclosureDataResult {
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
@@ -18,16 +25,40 @@ interface UseDisclosureDataResult {
}
/**
* 披露日程数据 Hook
* @param stockCode - 股票代码
* 披露日程数据 Hook(支持延迟加载)
* @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => {
export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclosureDataResult => {
const { stockCode, enabled = true } = options;
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) {
setLoading(false);
return;
}
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController();
@@ -46,18 +77,25 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
} else {
setError("加载披露日程数据失败");
}
setLoading(false);
hasLoadedRef.current = true;
} catch (err: any) {
if (err.name === "CanceledError") return;
// 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
hasLoadedRef.current = true;
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled]);
return { disclosureSchedule, loading, error };
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { disclosureSchedule, loading: isLoading, error };
};

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Management } from "../types";
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseManagementDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseManagementDataResult {
management: Management[];
loading: boolean;
@@ -18,16 +25,40 @@ interface UseManagementDataResult {
}
/**
* 管理团队数据 Hook
* @param stockCode - 股票代码
* 管理团队数据 Hook(支持延迟加载)
* @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/
export const useManagementData = (stockCode?: string): UseManagementDataResult => {
export const useManagementData = (options: UseManagementDataOptions): UseManagementDataResult => {
const { stockCode, enabled = true } = options;
const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) {
setLoading(false);
return;
}
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController();
@@ -46,18 +77,27 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
} else {
setError("加载管理团队数据失败");
}
setLoading(false);
hasLoadedRef.current = true;
} catch (err: any) {
if (err.name === "CanceledError") return;
// 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
hasLoadedRef.current = true;
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled]);
return { management, loading, error };
// 派生 loading 状态enabled 但尚未完成首次加载时,视为 loading
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { management, loading: isLoading, error };
};

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