Compare commits

..

75 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
122 changed files with 9797 additions and 7644 deletions

1614
CLAUDE.md

File diff suppressed because it is too large Load Diff

95
app.py
View File

@@ -351,6 +351,7 @@ def generate_events_cache_key(args_dict):
params_str = json.dumps(filtered_params, sort_keys=True) params_str = json.dumps(filtered_params, sort_keys=True)
params_hash = hashlib.md5(params_str.encode()).hexdigest() params_hash = hashlib.md5(params_str.encode()).hexdigest()
return f"{EVENTS_CACHE_PREFIX}{params_hash}" return f"{EVENTS_CACHE_PREFIX}{params_hash}"
@@ -11004,7 +11005,9 @@ def get_events_by_mainline():
4. 按指定层级分组返回 4. 按指定层级分组返回
参数: 参数:
- recent_days: 近N天默认7天 - 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 - importance: 重要性筛选S,A,B,C 或 all
- group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA)默认lv2 - group_by: 分组方式 (lv1/lv2/lv3/具体概念ID如L2_AI_INFRA)默认lv2
@@ -11036,12 +11039,32 @@ def get_events_by_mainline():
from sqlalchemy import exists from sqlalchemy import exists
# 获取请求参数 # 获取请求参数
recent_days = request.args.get('recent_days', 7, type=int) 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') importance = request.args.get('importance', 'all')
group_by = request.args.get('group_by', 'lv2') # lv1/lv2/lv3 或具体ID 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) 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. 获取概念层级映射 ==================== # ==================== 1. 获取概念层级映射 ====================
# 调用 concept-api 获取层级结构 # 调用 concept-api 获取层级结构
@@ -11128,6 +11151,8 @@ def get_events_by_mainline():
# 日期筛选 # 日期筛选
query = query.filter(Event.created_at >= since_date) query = query.filter(Event.created_at >= since_date)
if until_date:
query = query.filter(Event.created_at <= until_date)
# 重要性筛选 # 重要性筛选
if importance != 'all': if importance != 'all':
@@ -11279,35 +11304,60 @@ def get_events_by_mainline():
else: else:
ungrouped_events.append(event_data) ungrouped_events.append(event_data)
# ==================== 5. 获取 lv2 概念涨跌幅 ==================== # ==================== 5. 获取概念涨跌幅(根据 group_by 参数) ====================
lv2_price_map = {} 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: try:
# 获取所有 lv2 名称 # 获取所有对应层级的名称
lv2_names = [group['lv2_name'] for group in mainline_groups.values() if group.get('lv2_name')] group_names = [group.get('group_name') or group.get(name_field) for group in mainline_groups.values()]
if lv2_names: group_names = [n for n in group_names if n] # 过滤掉空值
# 数据库中的 concept_name 带有 "[二级] " 前缀,需要添加前缀来匹配
lv2_names_with_prefix = [f'[二级] {name}' for name in lv2_names] if group_names:
# 数据库中的 concept_name 带有前缀,需要添加前缀来匹配
names_with_prefix = [f'{name_prefix}{name}' for name in group_names]
# 查询 concept_daily_stats 表获取最新涨跌幅 # 查询 concept_daily_stats 表获取最新涨跌幅
price_sql = text(''' price_sql = text('''
SELECT concept_name, avg_change_pct, trade_date SELECT concept_name, avg_change_pct, trade_date
FROM concept_daily_stats FROM concept_daily_stats
WHERE concept_type = 'lv2' WHERE concept_type = :concept_type
AND concept_name IN :names AND concept_name IN :names
AND trade_date = ( AND trade_date = (
SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = 'lv2' SELECT MAX(trade_date) FROM concept_daily_stats WHERE concept_type = :concept_type
) )
''') ''')
price_result = db.session.execute(price_sql, {'names': tuple(lv2_names_with_prefix)}).fetchall() price_result = db.session.execute(price_sql, {
'concept_type': db_concept_type,
'names': tuple(names_with_prefix)
}).fetchall()
for row in price_result: for row in price_result:
# 去掉 "[二级] " 前缀,用原始名称作为 key # 去掉前缀,用原始名称作为 key
original_name = row.concept_name.replace('[二级] ', '') if row.concept_name else '' original_name = row.concept_name.replace(name_prefix, '') if row.concept_name else ''
lv2_price_map[original_name] = { price_map[original_name] = {
'avg_change_pct': float(row.avg_change_pct) if row.avg_change_pct else None, '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 'trade_date': str(row.trade_date) if row.trade_date else None
} }
app.logger.info(f'[mainline] 获取 lv2 涨跌幅: {len(lv2_price_map)} 条, lv2_names 数量: {len(lv2_names)}') app.logger.info(f'[mainline] 获取 {current_level} 涨跌幅: {len(price_map)} 条, 查询名称数量: {len(group_names)}')
except Exception as price_err: except Exception as price_err:
app.logger.warning(f'[mainline] 获取 lv2 涨跌幅失败: {price_err}') app.logger.warning(f'[mainline] 获取 {current_level} 涨跌幅失败: {price_err}')
# ==================== 6. 整理返回数据 ==================== # ==================== 6. 整理返回数据 ====================
mainlines = [] mainlines = []
@@ -11319,11 +11369,12 @@ def get_events_by_mainline():
reverse=True reverse=True
) )
group['event_count'] = len(group['events']) group['event_count'] = len(group['events'])
# 添加涨跌幅数据(目前只支持 lv2
lv2_name = group.get('lv2_name', '') or group.get('group_name', '') # 添加涨跌幅数据(根据当前分组层级)
if lv2_name in lv2_price_map: group_name = group.get('group_name') or group.get(name_field, '')
group['avg_change_pct'] = lv2_price_map[lv2_name]['avg_change_pct'] if group_name in price_map:
group['price_date'] = lv2_price_map[lv2_name]['trade_date'] group['avg_change_pct'] = price_map[group_name]['avg_change_pct']
group['price_date'] = price_map[group_name]['trade_date']
else: else:
group['avg_change_pct'] = None group['avg_change_pct'] = None
group['price_date'] = None group['price_date'] = None

View File

@@ -14,10 +14,6 @@
"@fontsource/open-sans": "^4.5.0", "@fontsource/open-sans": "^4.5.0",
"@fontsource/raleway": "^4.5.0", "@fontsource/raleway": "^4.5.0",
"@fontsource/roboto": "^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", "@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12", "@splidejs/react-splide": "^0.7.12",
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",

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 { .fc .fc-button-primary {
color: #fff; color: #0A0A14;
color: var(--fc-button-text-color, #fff); color: var(--fc-button-text-color, #0A0A14);
background-color: #805AD5; background-color: #D4AF37;
background-color: var(--fc-button-bg-color, #805AD5); background-color: var(--fc-button-bg-color, #D4AF37);
border-color: #805AD5; border-color: #D4AF37;
border-color: var(--fc-button-border-color, #805AD5); border-color: var(--fc-button-border-color, #D4AF37);
font-weight: 600;
} }
.fc .fc-button-primary:hover { .fc .fc-button-primary:hover {
color: #fff; color: #0A0A14;
color: var(--fc-button-text-color, #fff); color: var(--fc-button-text-color, #0A0A14);
background-color: #6B46C1; background-color: #B8960C;
background-color: var(--fc-button-hover-bg-color, #6B46C1); background-color: var(--fc-button-hover-bg-color, #B8960C);
border-color: #6B46C1; border-color: #B8960C;
border-color: var(--fc-button-hover-border-color, #6B46C1); border-color: var(--fc-button-hover-border-color, #B8960C);
} }
.fc .fc-button-primary:disabled { /* not DRY */ .fc .fc-button-primary:disabled { /* not DRY */
color: #fff; color: #0A0A14;
color: var(--fc-button-text-color, #fff); color: var(--fc-button-text-color, #0A0A14);
background-color: #805AD5; background-color: #B8960C;
background-color: var(--fc-button-bg-color, #805AD5); background-color: var(--fc-button-bg-color, #B8960C);
border-color: #805AD5; border-color: #B8960C;
border-color: var(--fc-button-border-color, #805AD5); /* overrides :hover */ border-color: var(--fc-button-border-color, #B8960C); /* overrides :hover */
opacity: 1;
} }
.fc .fc-button-primary:focus { .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):active,
.fc .fc-button-primary:not(:disabled).fc-button-active { .fc .fc-button-primary:not(:disabled).fc-button-active {
color: #fff; color: #0A0A14;
color: var(--fc-button-text-color, #fff); color: var(--fc-button-text-color, #0A0A14);
background-color: #6B46C1; background-color: #B8960C;
background-color: var(--fc-button-active-bg-color, #6B46C1); background-color: var(--fc-button-active-bg-color, #B8960C);
border-color: #6B46C1; border-color: #B8960C;
border-color: var(--fc-button-active-border-color, #6B46C1); border-color: var(--fc-button-active-border-color, #B8960C);
} }
.fc .fc-button-primary:not(:disabled):active:focus, .fc .fc-button-primary:not(:disabled):active:focus,
.fc .fc-button-primary:not(:disabled).fc-button-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 { .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, Badge,
IconButton, IconButton,
Button, Button,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/** /**
* 可折叠模块标题组件 * 可折叠模块标题组件
@@ -38,9 +38,10 @@ const CollapsibleHeader = ({
onModeToggle = null, onModeToggle = null,
isLocked = false isLocked = false
}) => { }) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750'); // 深色主题 - 标题区块背景稍亮
const hoverBg = useColorModeValue('gray.100', 'gray.700'); const sectionBg = '#3D4A5C';
const headingColor = useColorModeValue('gray.700', 'gray.200'); const hoverBg = '#4A5568';
const headingColor = '#F7FAFC';
// 获取按钮文案 // 获取按钮文案
const getButtonText = () => { const getButtonText = () => {

View File

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

View File

@@ -86,9 +86,10 @@ const sectionReducer = (state, action) => {
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { user } = useAuth(); const { user } = useAuth();
const cardBg = PROFESSIONAL_COLORS.background.card; // 深色主题 - 与弹窗背景一致
const borderColor = PROFESSIONAL_COLORS.border.default; const cardBg = '#2D3748';
const textColor = PROFESSIONAL_COLORS.text.secondary; const borderColor = 'rgba(255, 255, 255, 0.08)';
const textColor = '#CBD5E0';
// 使用 useWatchlist Hook 管理自选股 // 使用 useWatchlist Hook 管理自选股
const { const {

View File

@@ -1,5 +1,5 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js // src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件 - 折叠手风琴样式 // 相关概念区组件 - 便当盒网格布局
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
@@ -10,94 +10,72 @@ import {
Spinner, Spinner,
Text, Text,
Badge, Badge,
VStack, SimpleGrid,
HStack, HStack,
Icon, Tooltip,
Collapse,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig'; import { getApiBase } from '@utils/apiConfig';
/** /**
* 单个概念组件(手风琴项 * 单个概念卡片组件(便当盒样式
*/ */
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => { const ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
const itemBg = useColorModeValue('white', 'gray.700'); // 深色主题固定颜色
const itemHoverBg = useColorModeValue('gray.50', 'gray.650'); const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
const borderColor = useColorModeValue('gray.200', 'gray.600'); const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
const conceptColor = useColorModeValue('blue.600', 'blue.300'); const borderColor = 'rgba(252, 129, 129, 0.3)';
const reasonBg = useColorModeValue('blue.50', 'gray.800'); const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
const reasonColor = useColorModeValue('gray.700', 'gray.200');
const iconColor = useColorModeValue('gray.500', 'gray.400'); const handleClick = () => {
if (isLocked && onLockedClick) {
onLockedClick();
return;
}
onNavigate(concept);
};
return ( return (
<Tooltip
label={concept.reason || concept.concept}
placement="top"
hasArrow
bg="gray.800"
color="white"
p={2}
borderRadius="md"
maxW="300px"
fontSize="xs"
>
<Box <Box
bg={cardBg}
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={borderColor}
borderRadius="md" borderRadius="lg"
overflow="hidden"
bg={itemBg}
>
{/* 概念标题行 - 可点击展开 */}
<Flex
px={3} px={3}
py={2.5} py={2}
cursor="pointer" cursor="pointer"
align="center" onClick={handleClick}
justify="space-between" _hover={{
_hover={{ bg: itemHoverBg }} bg: cardHoverBg,
onClick={onToggle} transform: 'translateY(-1px)',
transition="background 0.2s" 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 <Text
fontSize="sm" fontSize="sm"
fontWeight="medium" fontWeight="semibold"
color={conceptColor} color={conceptColor}
cursor="pointer" noOfLines={1}
_hover={{ textDecoration: 'underline' }}
onClick={(e) => {
e.stopPropagation();
onNavigate(concept);
}}
> >
{concept.concept} {concept.concept}
</Text> </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"
color={reasonColor}
lineHeight="1.8"
whiteSpace="pre-wrap"
>
{concept.reason || '暂无关联原因说明'}
</Text>
</Box>
</Collapse>
</Box> </Box>
</Tooltip>
); );
}; };
@@ -120,16 +98,14 @@ const RelatedConceptsSection = ({
const [concepts, setConcepts] = useState([]); const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// 记录每个概念的展开状态
const [expandedItems, setExpandedItems] = useState({});
const navigate = useNavigate(); const navigate = useNavigate();
// 颜色配置 // 颜色配置 - 使用深色主题固定颜色
const sectionBg = useColorModeValue('gray.50', 'gray.750'); const sectionBg = 'transparent';
const headingColor = useColorModeValue('gray.700', 'gray.200'); const headingColor = '#e2e8f0';
const textColor = useColorModeValue('gray.600', 'gray.400'); const textColor = '#a0aec0';
const countBadgeBg = useColorModeValue('blue.100', 'blue.800'); const countBadgeBg = '#3182ce';
const countBadgeColor = useColorModeValue('blue.700', 'blue.200'); const countBadgeColor = '#ffffff';
// 获取相关概念 // 获取相关概念
useEffect(() => { useEffect(() => {
@@ -162,10 +138,6 @@ const RelatedConceptsSection = ({
const data = await response.json(); const data = await response.json();
if (data.success && Array.isArray(data.data)) { if (data.success && Array.isArray(data.data)) {
setConcepts(data.data); setConcepts(data.data);
// 默认展开第一个
if (data.data.length > 0) {
setExpandedItems({ 0: true });
}
} else { } else {
setConcepts([]); setConcepts([]);
} }
@@ -182,18 +154,6 @@ const RelatedConceptsSection = ({
fetchConcepts(); fetchConcepts();
}, [eventId]); }, [eventId]);
// 切换某个概念的展开状态
const toggleItem = (index) => {
if (isLocked && onLockedClick) {
onLockedClick();
return;
}
setExpandedItems(prev => ({
...prev,
[index]: !prev[index]
}));
};
// 跳转到概念中心 // 跳转到概念中心
const handleNavigate = (concept) => { const handleNavigate = (concept) => {
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`); navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
@@ -237,7 +197,7 @@ const RelatedConceptsSection = ({
</HStack> </HStack>
</Flex> </Flex>
{/* 概念列表 - 手风琴样式 */} {/* 概念列表 - 便当盒网格布局 */}
{hasNoConcepts ? ( {hasNoConcepts ? (
<Box py={2}> <Box py={2}>
{error ? ( {error ? (
@@ -247,17 +207,17 @@ const RelatedConceptsSection = ({
)} )}
</Box> </Box>
) : ( ) : (
<VStack spacing={2} align="stretch"> <SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
{concepts.map((concept, index) => ( {concepts.map((concept, index) => (
<ConceptItem <ConceptCard
key={concept.id || index} key={concept.id || index}
concept={concept} concept={concept}
isExpanded={!!expandedItems[index]}
onToggle={() => toggleItem(index)}
onNavigate={handleNavigate} onNavigate={handleNavigate}
isLocked={isLocked}
onLockedClick={onLockedClick}
/> />
))} ))}
</VStack> </SimpleGrid>
)} )}
</Box> </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 // src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js
// 关注事件下拉菜单组件 // 关注事件下拉菜单组件
import React, { memo } from 'react'; import React, { memo, useState } from 'react';
import { import {
Menu, Menu,
MenuButton, MenuButton,
@@ -22,6 +22,7 @@ import { FiCalendar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents'; import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
import { getEventDetailUrl } from '@/utils/idEncoder'; import { getEventDetailUrl } from '@/utils/idEncoder';
import FavoriteButton from '@/components/FavoriteButton';
/** /**
* 关注事件下拉菜单组件 * 关注事件下拉菜单组件
@@ -30,6 +31,7 @@ import { getEventDetailUrl } from '@/utils/idEncoder';
*/ */
const FollowingEventsMenu = memo(() => { const FollowingEventsMenu = memo(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [unfollowingId, setUnfollowingId] = useState(null);
const { const {
followingEvents, followingEvents,
eventsLoading, eventsLoading,
@@ -40,6 +42,17 @@ const FollowingEventsMenu = memo(() => {
handleUnfollowEvent handleUnfollowEvent
} = useFollowingEvents(); } = 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 titleColor = useColorModeValue('gray.600', 'gray.300');
const loadingTextColor = useColorModeValue('gray.500', 'gray.300'); const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
const emptyTextColor = useColorModeValue('gray.500', 'gray.300'); const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -108,27 +121,6 @@ const FollowingEventsMenu = memo(() => {
</HStack> </HStack>
</Box> </Box>
<HStack flexShrink={0} spacing={1}> <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' && ( {typeof ev.related_avg_chg === 'number' && (
<Badge <Badge
@@ -155,23 +147,21 @@ const FollowingEventsMenu = memo(() => {
{ev.related_week_chg.toFixed(2)}% {ev.related_week_chg.toFixed(2)}%
</Badge> </Badge>
)} )}
{/* 取消关注按钮 */} {/* 取消关注按钮 - 使用 FavoriteButton */}
<Box <Box
as="span"
fontSize="xs"
color="red.500"
cursor="pointer"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: 'red.50' }}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleUnfollowEvent(ev.id);
}} }}
> >
取消 <FavoriteButton
isFavorite={true}
isLoading={unfollowingId === ev.id}
onClick={() => handleUnfollow(ev.id)}
size="sm"
colorScheme="gold"
showTooltip={true}
/>
</Box> </Box>
</HStack> </HStack>
</HStack> </HStack>

View File

@@ -1,7 +1,7 @@
// src/components/Navbars/components/FeatureMenus/WatchlistMenu.js // src/components/Navbars/components/FeatureMenus/WatchlistMenu.js
// 自选股下拉菜单组件 // 自选股下拉菜单组件
import React, { memo } from 'react'; import React, { memo, useState } from 'react';
import { import {
Menu, Menu,
MenuButton, MenuButton,
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
import { FiStar } from 'react-icons/fi'; import { FiStar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useWatchlist } from '../../../../hooks/useWatchlist'; import { useWatchlist } from '../../../../hooks/useWatchlist';
import FavoriteButton from '@/components/FavoriteButton';
/** /**
* 自选股下拉菜单组件 * 自选股下拉菜单组件
@@ -29,6 +30,7 @@ import { useWatchlist } from '../../../../hooks/useWatchlist';
*/ */
const WatchlistMenu = memo(() => { const WatchlistMenu = memo(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [removingCode, setRemovingCode] = useState(null);
const { const {
watchlistQuotes, watchlistQuotes,
watchlistLoading, watchlistLoading,
@@ -39,6 +41,17 @@ const WatchlistMenu = memo(() => {
handleRemoveFromWatchlist handleRemoveFromWatchlist
} = useWatchlist(); } = 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 titleColor = useColorModeValue('gray.600', 'gray.300');
const loadingTextColor = useColorModeValue('gray.500', 'gray.300'); const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
const emptyTextColor = useColorModeValue('gray.500', 'gray.300'); const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -114,21 +127,19 @@ const WatchlistMenu = memo(() => {
(item.current_price || '-')} (item.current_price || '-')}
</Text> </Text>
<Box <Box
as="span"
fontSize="xs"
color="red.500"
cursor="pointer"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: 'red.50' }}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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> </Box>
</HStack> </HStack>
</HStack> </HStack>

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 // 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 { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig'; import {
loadFollowingEvents as loadFollowingEventsAction,
toggleFollowEvent
} from '../store/slices/stockSlice';
const EVENTS_PAGE_SIZE = 8; const EVENTS_PAGE_SIZE = 8;
/** /**
* 关注事件管理 Hook * 关注事件管理 Hook(导航栏专用)
* 提供事件加载、分页、取消关注等功能 * 提供关注事件加载、分页、取消关注等功能
* 监听 Redux 中的 followingEvents 变化,自动同步
* *
* @returns {{ * @returns {{
* followingEvents: Array, * followingEvents: Array,
@@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8;
*/ */
export const useFollowingEvents = () => { export const useFollowingEvents = () => {
const toast = useToast(); const toast = useToast();
const [followingEvents, setFollowingEvents] = useState([]); const dispatch = useDispatch();
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsPage, setEventsPage] = useState(1); const [eventsPage, setEventsPage] = useState(1);
// 加载关注事件 // 从 Redux 获取关注事件数据(与 GlobalSidebar 共用)
const loadFollowingEvents = useCallback(async () => { const followingEvents = useSelector(state => state.stock.followingEvents || []);
try { const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
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 获取关注事件列表长度(用于监听变化)
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) => { const handleUnfollowEvent = useCallback(async (eventId) => {
try { try {
const base = getApiBase(); // 通过 Redux action 取消关注(乐观更新)
const resp = await fetch(base + `/api/events/${eventId}/follow`, { await dispatch(toggleFollowEvent({
method: 'POST', eventId,
credentials: 'include' isFollowing: true // 表示当前已关注,需要取消
}); })).unwrap();
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 }); toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
} else {
toast({ title: '操作失败', status: 'error', duration: 2000 });
}
} catch (e) { } 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 { return {
followingEvents, followingEvents,

View File

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

View File

@@ -21,6 +21,9 @@ performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效 // ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css'; // import './styles/brainwave.css';
// 导入全局滚动条隐藏样式
import './styles/scrollbar-hide.css';
// 导入 Select 下拉框颜色修复样式 // 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css'; import './styles/select-fix.css';

View File

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

View File

@@ -12,13 +12,62 @@
/** /**
* Z-Index 层级管理 * Z-Index 层级管理
* 统一管理 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 = { export const Z_INDEX = {
BACK_TO_TOP: 1000, // 返回顶部按钮 // === 页面内部元素 (0-99) ===
NAVBAR: 1100, // 导航栏 BACKGROUND: 0, // 背景层
MODAL: 1200, // 模态框 CARD_CONTENT: 1, // 卡片内容
TOAST: 1300, // 提示消息 CARD_OVERLAY: 2, // 卡片覆盖层
TOOLTIP: 1400, // 工具提示
// === 页面级浮动元素 (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 = { export const LAYOUT_SIZE = {
navbarHeight: '80px', navbarHeight: '60px', // 导航栏统一高度
footerHeight: 'auto', 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', updated_at: '2024-10-08T10:00:00Z',
tags: ['季度复盘', '半导体', 'Q3'], tags: ['季度复盘', '半导体', 'Q3'],
stocks: ['688981.SH', '002371.SZ'] 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, is_recurring: true,
recurrence_rule: 'weekly', recurrence_rule: 'weekly',
created_at: '2025-01-01T10:00:00Z' 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

@@ -1142,6 +1142,138 @@ function generateTransmissionChain(industry, index) {
return { nodes, edges }; 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 } * @param {Object} timeRange - 时间范围 { startTime, endTime }
@@ -1166,7 +1298,10 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const timeSpan = endTime.getTime() - startTime.getTime(); const timeSpan = endTime.getTime() - startTime.getTime();
for (let i = 0; i < count; i++) { 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 imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length]; const eventType = eventTypes[i % eventTypes.length];
@@ -1217,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({ events.push({
id: `dynamic_${i + 1}`, id: `dynamic_${i + 1}`,
title: generateEventTitle(industry, i), title: eventTitle,
description: generateEventDescription(industry, imp, i), description: eventDescription,
content: generateEventDescription(industry, imp, i), content: eventDescription,
event_type: eventType, event_type: eventType,
importance: imp, importance: imp,
status: 'published', status: 'published',
@@ -1234,7 +1391,9 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
related_avg_chg: parseFloat(relatedAvgChg), related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg), related_max_chg: parseFloat(relatedMaxChg),
related_week_chg: parseFloat(relatedWeekChg), 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生成 is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
industry: industry, industry: industry,
related_stocks: relatedStocks, related_stocks: relatedStocks,

View File

@@ -55,41 +55,77 @@ export const generateMarketData = (stockCode) => {
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还 repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
}, },
securities: { 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, // 融券卖出 sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还 repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
} }
})) }))
}, },
// 大单统计 - 包含 daily_stats 数组 // 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型
bigDealData: { bigDealData: {
success: true, success: true,
data: [], data: [],
daily_stats: Array(10).fill(null).map((_, i) => ({ 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], date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
big_buy: Math.floor(Math.random() * 300000000) + 100000000, count,
big_sell: Math.floor(Math.random() * 300000000) + 80000000, total_volume: parseFloat(totalVolume.toFixed(2)),
medium_buy: Math.floor(Math.random() * 200000000) + 60000000, total_amount: parseFloat(totalAmount.toFixed(2)),
medium_sell: Math.floor(Math.random() * 200000000) + 50000000, avg_price: avgPrice,
small_buy: Math.floor(Math.random() * 100000000) + 30000000, deals
small_sell: Math.floor(Math.random() * 100000000) + 25000000 };
})) })
}, },
// 异动分析 - 包含 grouped_data 数组 // 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型
unusualData: { unusualData: {
success: true, success: true,
data: [], data: [],
grouped_data: Array(5).fill(null).map((_, i) => ({ 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], date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
events: [ total_buy: totalBuy,
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' }, total_sell: totalSell,
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' }, net_amount: totalBuy - totalSell,
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' } buyers,
], sellers,
count: 3 info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型
})) };
})
}, },
// 股权质押 - 匹配 PledgeData[] 类型 // 股权质押 - 匹配 PledgeData[] 类型

View File

@@ -351,15 +351,21 @@ export const accountHandlers = [
const body = await request.json(); const body = await request.json();
console.log('[Mock] 创建投资计划:', body); console.log('[Mock] 创建投资计划:', body);
// 生成唯一 ID使用时间戳避免冲突
const newId = Date.now();
const newPlan = { const newPlan = {
id: mockInvestmentPlans.length + 301, id: newId,
user_id: currentUser.id, user_id: currentUser.id,
...body, ...body,
// 确保 target_date 字段存在(兼容前端发送的 date 字段)
target_date: body.target_date || body.date,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
mockInvestmentPlans.push(newPlan); mockInvestmentPlans.push(newPlan);
console.log('[Mock] 新增计划/复盘,当前总数:', mockInvestmentPlans.length);
return HttpResponse.json({ return HttpResponse.json({
success: true, 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] 日历事件详情:', { console.log('[Mock] 日历事件详情:', {
currentUserId: currentUser.id, currentUserId: currentUser.id,
calendarEvents: calendarEvents.length, calendarEvents: calendarEvents.length,
investmentPlansAsEvents: investmentPlansAsEvents.length, investmentPlansAsEvents: investmentPlansAsEvents.length,
total: filteredEvents.length, total: filteredEvents.length,
plansCount: filteredEvents.filter(e => e.type === 'plan').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({ return HttpResponse.json({

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 });
}
}),
// ==================== 事件详情相关 ==================== // ==================== 事件详情相关 ====================
// 获取事件详情 // 获取事件详情
@@ -1585,187 +1738,4 @@ export const eventHandlers = [
); );
} }
}), }),
// ==================== 主线模式相关 ====================
// 获取按主线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);
// 分组方式: 'lv1' | 'lv2' | 'lv3' | 具体的概念ID如 'L1_TMT', 'L2_AI_INFRA', 'L3_AI_CHIP'
const groupBy = url.searchParams.get('group_by') || 'lv2';
console.log('[Mock Event] 获取主线数据:', { recentDays, importance, limitPerMainline, groupBy });
try {
// 生成 mock 事件数据
const allEvents = generateDynamicNewsEvents(100);
// 定义完整的 lv1 -> lv2 -> lv3 层级结构
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 => k.concept || k.name || k).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) {
// 筛选特定概念ID
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 {
// 默认 lv2
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 mainlines = Object.values(mainlineGroups)
.map(group => ({
...group,
events: group.events.slice(0, limitPerMainline),
event_count: Math.min(group.events.length, limitPerMainline)
}))
.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;
console.log('[Mock Event] 主线数据生成完成:', {
mainlineCount: mainlines.length,
totalEvents: allEvents.length,
ungroupedCount,
groupBy
});
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 }) => { http.get('/api/market/statistics', async ({ request }) => {
await delay(200); await delay(200);
const url = new URL(request.url); const url = new URL(request.url);

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice'; import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice'; import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理 import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import planningReducer from './slices/planningSlice'; // ✅ 投资规划中心状态管理
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
// ⚡ 基础 reducers首屏必需 // ⚡ 基础 reducers首屏必需
@@ -19,6 +20,7 @@ const staticReducers = {
authModal: authModalReducer, // ✅ 认证弹窗状态管理 authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理 subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端) device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
planning: planningReducer, // ✅ 投资规划中心状态管理
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API [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 }] // 自选股列表 [{ stock_code, stock_name }]
watchlist: [], watchlist: [],
// 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }]
watchlistQuotes: [],
// 关注事件列表 [{ id, title, event_type, ... }]
followingEvents: [],
// 用户评论列表 [{ id, content, event_id, ... }]
eventComments: [],
// 全部股票列表(用于前端模糊搜索)[{ code, name }] // 全部股票列表(用于前端模糊搜索)[{ code, name }]
allStocks: [], allStocks: [],
@@ -370,6 +505,9 @@ const stockSlice = createSlice({
historicalEvents: false, historicalEvents: false,
chainAnalysis: false, chainAnalysis: false,
watchlist: false, watchlist: false,
watchlistQuotes: false,
followingEvents: false,
eventComments: false,
allStocks: false allStocks: false
}, },
@@ -517,6 +655,18 @@ const stockSlice = createSlice({
state.loading.watchlist = false; 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 ===== // ===== loadAllStocks =====
.addCase(loadAllStocks.pending, (state) => { .addCase(loadAllStocks.pending, (state) => {
state.loading.allStocks = true; state.loading.allStocks = true;
@@ -563,6 +713,47 @@ const stockSlice = createSlice({
.addCase(toggleWatchlist.fulfilled, (state) => { .addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage // 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist); 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, PlanFormData,
PlanningContextValue, PlanningContextValue,
} from './investment'; } 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, message,
Space, Space,
Spin, Spin,
ConfigProvider,
theme,
} from 'antd'; } from 'antd';
import type { SelectProps } from 'antd'; import type { SelectProps } from 'antd';
import { import {
@@ -34,7 +36,13 @@ import 'dayjs/locale/zh-cn';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useAppDispatch } from '@/store/hooks'; import { useAppDispatch } from '@/store/hooks';
import { usePlanningData } from './PlanningContext'; import {
fetchAllEvents,
optimisticAddEvent,
replaceEvent,
removeEvent,
optimisticUpdateEvent,
} from '@/store/slices/planningSlice';
import './EventFormModal.less'; import './EventFormModal.less';
import type { InvestmentEvent, EventType } from '@/types'; import type { InvestmentEvent, EventType } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
@@ -184,7 +192,6 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
label = '事件', label = '事件',
apiEndpoint = 'investment-plans', apiEndpoint = 'investment-plans',
}) => { }) => {
const { loadAllData } = usePlanningData();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [form] = Form.useForm<FormData>(); const [form] = Form.useForm<FormData>();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -275,7 +282,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
setStockOptions(options.length > 0 ? options : watchlistOptions); setStockOptions(options.length > 0 ? options : watchlistOptions);
}, [allStocks, watchlistOptions]); }, [allStocks, watchlistOptions]);
// 保存数据 // 保存数据(新建模式使用乐观更新)
const handleSave = useCallback(async (): Promise<void> => { const handleSave = useCallback(async (): Promise<void> => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
@@ -315,29 +322,104 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}` ? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
: `${base}/api/account/${apiEndpoint}`; : `${base}/api/account/${apiEndpoint}`;
const method = mode === 'edit' ? 'PUT' : 'POST'; // ===== 新建模式:乐观更新 =====
if (mode === 'create') {
const tempId = -Date.now(); // 负数临时 ID避免与服务器 ID 冲突
const tempEvent: InvestmentEvent = {
id: tempId,
title: values.title,
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, { const response = await fetch(url, {
method, method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'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', credentials: 'include',
body: JSON.stringify(requestData), body: JSON.stringify(requestData),
}); });
if (response.ok) { if (response.ok) {
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, { logger.info('EventFormModal', `更新${label}成功`, {
itemId: editingEvent?.id, itemId: editingEvent.id,
title: values.title, title: values.title,
}); });
message.success(mode === 'edit' ? '修改成功' : '添加成功'); message.success('修改成功');
onClose();
onSuccess(); onSuccess();
loadAllData();
} else { } else {
throw new Error('保存失败'); throw new Error('保存失败');
} }
} catch (error) {
// ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventFormModal', 'handleSave edit rollback', error);
message.error('修改失败,请重试');
}
return;
}
} catch (error) { } catch (error) {
if (error instanceof Error && error.message !== '保存失败') { if (error instanceof Error && error.message !== '保存失败') {
// 表单验证错误,不显示额外提示 // 表单验证错误,不显示额外提示
@@ -350,7 +432,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
} finally { } finally {
setSaving(false); 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 // 监听键盘快捷键 Ctrl + Enter
useEffect(() => { useEffect(() => {
@@ -393,7 +475,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
// 判断是否显示自选股列表 // 判断是否显示自选股列表
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions; const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
// 股票选择器选项配置 // 股票选择器选项配置(黑金主题)
const selectProps: SelectProps<string[]> = { const selectProps: SelectProps<string[]> = {
mode: 'multiple', mode: 'multiple',
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索', placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
@@ -401,12 +483,15 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
onSearch: handleStockSearch, onSearch: handleStockSearch,
loading: watchlistLoading || allStocksLoading, loading: watchlistLoading || allStocksLoading,
notFoundContent: 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" /> <Spin size="small" />
<span style={{ marginLeft: 8 }}>...</span> <span style={{ marginLeft: 8 }}>...</span>
</div> </div>
) : '暂无结果', ) : <span style={{ color: 'rgba(255,255,255,0.4)' }}></span>,
options: stockOptions, options: stockOptions,
style: {
width: '100%',
},
onFocus: () => { onFocus: () => {
if (stockOptions.length === 0) { if (stockOptions.length === 0) {
setStockOptions(watchlistOptions); setStockOptions(watchlistOptions);
@@ -416,41 +501,49 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
const { label: tagLabel, closable, onClose: onTagClose } = props; const { label: tagLabel, closable, onClose: onTagClose } = props;
return ( return (
<Tag <Tag
color="blue"
closable={closable} closable={closable}
onClose={onTagClose} 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} {tagLabel}
</Tag> </Tag>
); );
}, },
popupRender: (menu) => ( popupRender: (menu) => (
<> <div style={{
background: '#1A1A2E',
border: '1px solid rgba(212, 175, 55, 0.2)',
borderRadius: '8px',
}}>
{isShowingWatchlist && watchlistOptions.length > 0 && ( {isShowingWatchlist && watchlistOptions.length > 0 && (
<> <>
<div style={{ padding: '4px 8px 0' }}> <div style={{ padding: '8px 12px 4px' }}>
<span style={{ fontSize: 12, color: '#999' }}> <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)' }}>
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} /> <StarOutlined style={{ marginRight: 4, color: '#D4AF37' }} />
</span> </span>
</div> </div>
<Divider style={{ margin: '4px 0 0' }} /> <Divider style={{ margin: '4px 0 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
</> </>
)} )}
{menu} {menu}
{!isShowingWatchlist && searchText && ( {!isShowingWatchlist && searchText && (
<> <>
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
<div style={{ padding: '0 8px 4px' }}> <div style={{ padding: '0 12px 8px' }}>
<span style={{ fontSize: 12, color: '#999' }}> <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
<BulbOutlined style={{ marginRight: 4 }} /> <BulbOutlined style={{ marginRight: 4 }} />
</span> </span>
</div> </div>
</> </>
)} )}
</> </div>
), ),
}; };
@@ -462,9 +555,47 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
return eventType === 'plan' ? '创建计划' : '创建复盘'; 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 ( return (
<Modal <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} open={isOpen}
onCancel={onClose} onCancel={onClose}
width={600} width={600}
@@ -472,18 +603,64 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
maskClosable={true} maskClosable={true}
keyboard keyboard
className="event-form-modal" className="event-form-modal"
styles={modalStyles}
closeIcon={
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '16px' }}></span>
}
footer={ footer={
<div className="modal-footer"> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button <Button
type="primary" type="primary"
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={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()} {getButtonText()}
</Button> </Button>
</div> </div>
} }
>
<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}> <div ref={modalContentRef}>
{isOpen && <Form {isOpen && <Form
@@ -501,7 +678,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 标题 */} {/* 标题 */}
<Form.Item <Form.Item
name="title" 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={[ rules={[
{ required: true, message: '请输入标题' }, { required: true, message: '请输入标题' },
{ max: 50, message: '标题不能超过50个字符' }, { max: 50, message: '标题不能超过50个字符' },
@@ -511,17 +688,31 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
placeholder={getTitlePlaceholder()} placeholder={getTitlePlaceholder()}
maxLength={50} maxLength={50}
showCount 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>
{/* 日期 */} {/* 日期 */}
<Form.Item <Form.Item
name="date" 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: '请选择日期' }]} rules={[{ required: true, message: '请选择日期' }]}
> >
<DatePicker <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" format="YYYY-MM-DD"
placeholder="选择日期" placeholder="选择日期"
allowClear={false} allowClear={false}
@@ -531,7 +722,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 描述/内容 - 上下布局 */} {/* 描述/内容 - 上下布局 */}
<Form.Item <Form.Item
name="content" 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: '请输入内容' }]} rules={[{ required: true, message: '请输入内容' }]}
labelCol={{ span: 24 }} labelCol={{ span: 24 }}
wrapperCol={{ span: 24 }} wrapperCol={{ span: 24 }}
@@ -542,18 +733,28 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
rows={8} rows={8}
showCount showCount
maxLength={2000} 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>
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */} {/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Space wrap size="small" className="template-buttons"> <Space wrap size="small">
{templates.map((template) => ( {templates.map((template) => (
<Button <Button
key={template.label} key={template.label}
size="small" size="small"
onClick={() => handleInsertTemplate(template)} 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} {template.label}
</Button> </Button>
@@ -564,12 +765,13 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
{/* 关联股票 */} {/* 关联股票 */}
<Form.Item <Form.Item
name="stocks" name="stocks"
label={<span style={{ fontWeight: 600 }}></span>} label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}></span>}
> >
<Select {...selectProps} /> <Select {...selectProps} />
</Form.Item> </Form.Item>
</Form>} </Form>}
</div> </div>
</ConfigProvider>
</Modal> </Modal>
); );
}; };

View File

@@ -1,5 +1,5 @@
/** /**
* EventPanel - * EventPanel - (Redux )
* *
* *
* props * props
@@ -17,15 +17,23 @@ import {
Spinner, Spinner,
Center, Center,
Icon, Icon,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiFileText } from 'react-icons/fi'; 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 { EventFormModal } from './EventFormModal';
import { EventCard } from './EventCard'; import { FUIEventCard } from './FUIEventCard';
import type { InvestmentEvent } from '@/types'; import type { InvestmentEvent } from '@/types';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
/** /**
* EventPanel Props * EventPanel Props
@@ -51,15 +59,16 @@ export const EventPanel: React.FC<EventPanelProps> = ({
label, label,
openModalTrigger, openModalTrigger,
}) => { }) => {
const { const dispatch = useAppDispatch();
allEvents, const toast = useToast();
loadAllData,
loading, // Redux 状态
toast, const plans = useAppSelector(selectPlans);
textColor, const reviews = useAppSelector(selectReviews);
secondaryText, const loading = useAppSelector(selectPlanningLoading);
cardBg,
} = usePlanningData(); // 根据类型选择事件列表
const events = type === 'plan' ? plans : reviews;
// 弹窗状态 // 弹窗状态
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
@@ -69,9 +78,6 @@ export const EventPanel: React.FC<EventPanelProps> = ({
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发 // 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
const prevTriggerRef = useRef<number>(openModalTrigger || 0); const prevTriggerRef = useRef<number>(openModalTrigger || 0);
// 筛选事件列表(按类型过滤,排除系统事件)
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
// 监听外部触发打开新建模态框(修复 bug只在值变化时触发 // 监听外部触发打开新建模态框(修复 bug只在值变化时触发
useEffect(() => { useEffect(() => {
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) { if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
@@ -99,14 +105,17 @@ export const EventPanel: React.FC<EventPanelProps> = ({
setEditingItem(null); setEditingItem(null);
}; };
// 删除数据 // 删除数据 - 乐观更新模式
const handleDelete = async (id: number): Promise<void> => { const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return; if (!window.confirm('确定要删除吗?')) return;
// ① 立即从 UI 移除
dispatch(removeEvent(id));
// ② 后台发送 API 请求
try { try {
const base = getApiBase(); 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', method: 'DELETE',
credentials: 'include', credentials: 'include',
}); });
@@ -118,23 +127,34 @@ export const EventPanel: React.FC<EventPanelProps> = ({
status: 'success', status: 'success',
duration: 2000, duration: 2000,
}); });
loadAllData(); } else {
throw new Error('删除失败');
} }
} catch (error) { } catch (error) {
logger.error('EventPanel', 'handleDelete', error, { itemId: id }); // ③ 失败回滚 - 重新加载数据
dispatch(fetchAllEvents());
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
toast({ toast({
title: '删除失败', title: '删除失败,请重试',
status: 'error', status: 'error',
duration: 3000, duration: 3000,
}); });
} }
}; };
// 刷新数据
const handleRefresh = useCallback(() => {
dispatch(fetchAllEvents());
}, [dispatch]);
// 使用 useCallback 优化回调函数 // 使用 useCallback 优化回调函数
const handleEdit = useCallback((item: InvestmentEvent) => { const handleEdit = useCallback((item: InvestmentEvent) => {
handleOpenModal(item); handleOpenModal(item);
}, []); }, []);
// 颜色主题
const secondaryText = 'rgba(255, 255, 255, 0.6)';
return ( return (
<Box> <Box>
<VStack align="stretch" spacing={4}> <VStack align="stretch" spacing={4}>
@@ -150,17 +170,13 @@ export const EventPanel: React.FC<EventPanelProps> = ({
</VStack> </VStack>
</Center> </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 => ( {events.map(event => (
<EventCard <FUIEventCard
key={event.id} key={event.id}
event={event} event={event}
variant="list"
colorScheme={colorScheme} colorScheme={colorScheme}
label={label} label={label}
textColor={textColor}
secondaryText={secondaryText}
cardBg={cardBg}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -176,7 +192,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
mode={modalMode} mode={modalMode}
eventType={type} eventType={type}
editingEvent={editingItem} editingEvent={editingItem}
onSuccess={loadAllData} onSuccess={handleRefresh}
label={label} label={label}
apiEndpoint="investment-plans" 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, Badge,
Center, Center,
Spinner, Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useColorModeValue, useColorModeValue,
useToast, useToast,
useDisclosure, useDisclosure,
@@ -34,7 +28,7 @@ import { useNotification } from '@contexts/NotificationContext';
import EventScrollList from './EventScrollList'; import EventScrollList from './EventScrollList';
import ModeToggleButtons from './ModeToggleButtons'; import ModeToggleButtons from './ModeToggleButtons';
import PaginationControl from './PaginationControl'; import PaginationControl from './PaginationControl';
import DynamicNewsDetailPanel from '@components/EventDetailPanel'; import EventDetailModal from '../EventDetailModal';
import CompactSearchBox from '../SearchFilters/CompactSearchBox'; import CompactSearchBox from '../SearchFilters/CompactSearchBox';
import { import {
fetchDynamicNews, fetchDynamicNews,
@@ -692,21 +686,12 @@ const [currentMode, setCurrentMode] = useState('vertical');
</Box> </Box>
</CardBody> </CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */} {/* 四排/主线模式详情弹窗 - 深色风格 */}
{isModalOpen && ( <EventDetailModal
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside"> open={isModalOpen}
<ModalOverlay /> onClose={onModalClose}
<ModalContent maxW="1600px" mx="auto" my={8}> event={modalEvent}
<ModalHeader> />
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
)}
</Card> </Card>
); );
}); });

View File

@@ -25,9 +25,11 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons"; import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons";
import { FiTrendingUp, FiZap } from "react-icons/fi"; import { FiTrendingUp, FiZap } from "react-icons/fi";
import { FireOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { Select } from "antd"; import { Select } from "antd";
import MiniEventCard from "../../EventCard/MiniEventCard";
import { getApiBase } from "@utils/apiConfig"; import { getApiBase } from "@utils/apiConfig";
import { getChangeColor } from "@utils/colorUtils";
import "../../SearchFilters/CompactSearchBox.css"; import "../../SearchFilters/CompactSearchBox.css";
// 固定深色主题颜色 // 固定深色主题颜色
@@ -47,6 +49,179 @@ const COLORS = {
// 每次加载的事件数量 // 每次加载的事件数量
const EVENTS_PER_LOAD = 12; const EVENTS_PER_LOAD = 12;
/**
* 格式化时间显示 - 始终显示日期,避免跨天混淆
*/
const formatEventTime = (dateStr) => {
if (!dateStr) return "";
const date = dayjs(dateStr);
const now = dayjs();
const isToday = date.isSame(now, "day");
const isYesterday = date.isSame(now.subtract(1, "day"), "day");
// 始终显示日期,用标签区分今天/昨天
if (isToday) {
return `今天 ${date.format("MM-DD HH:mm")}`;
} else if (isYesterday) {
return `昨天 ${date.format("MM-DD HH:mm")}`;
} else {
return date.format("MM-DD HH:mm");
}
};
/**
* 根据涨跌幅获取背景色
*/
const getChangeBgColor = (value) => {
if (value == null || isNaN(value)) return "transparent";
const absChange = Math.abs(value);
if (value > 0) {
if (absChange >= 5) return "rgba(239, 68, 68, 0.12)";
if (absChange >= 3) return "rgba(239, 68, 68, 0.08)";
return "rgba(239, 68, 68, 0.05)";
} else if (value < 0) {
if (absChange >= 5) return "rgba(16, 185, 129, 0.12)";
if (absChange >= 3) return "rgba(16, 185, 129, 0.08)";
return "rgba(16, 185, 129, 0.05)";
}
return "transparent";
};
/**
* 单个事件项组件 - 卡片式布局
*/
const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => {
// 使用 related_max_chg 作为主要涨幅显示
const maxChange = event.related_max_chg;
const avgChange = event.related_avg_chg;
const hasMaxChange = maxChange != null && !isNaN(maxChange);
const hasAvgChange = avgChange != null && !isNaN(avgChange);
// 用于背景色的涨幅(使用平均超额)
const bgValue = avgChange;
return (
<Box
w="100%"
cursor="pointer"
onClick={() => onEventClick?.(event)}
bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)}
borderWidth="1px"
borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor}
borderRadius="lg"
p={3}
mb={2}
_hover={{
bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)",
borderColor: isSelected ? "#63b3ed" : "#5a6070",
transform: "translateY(-1px)",
}}
transition="all 0.2s ease"
>
{/* 第一行:时间 */}
<Text
fontSize="xs"
color={COLORS.secondaryTextColor}
mb={1.5}
>
{formatEventTime(event.created_at || event.event_time)}
</Text>
{/* 第二行:标题 */}
<Text
fontSize="sm"
color="#e2e8f0"
fontWeight="medium"
noOfLines={2}
lineHeight="1.5"
mb={2}
_hover={{ textDecoration: "underline", color: "#fff" }}
>
{event.title}
</Text>
{/* 第三行:涨跌幅指标 */}
{(hasMaxChange || hasAvgChange) && (
<HStack spacing={2} flexWrap="wrap">
{/* 最大超额 */}
{hasMaxChange && (
<Box
bg={maxChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
borderWidth="1px"
borderColor={maxChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
borderRadius="md"
px={2}
py={1}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
最大超额
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={maxChange > 0 ? "#fc8181" : "#68d391"}
>
{maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}%
</Text>
</Box>
)}
{/* 平均超额 */}
{hasAvgChange && (
<Box
bg={avgChange > 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"}
borderWidth="1px"
borderColor={avgChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"}
borderRadius="md"
px={2}
py={1}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
平均超额
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={avgChange > 0 ? "#fc8181" : "#68d391"}
>
{avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}%
</Text>
</Box>
)}
{/* 超预期得分 */}
{event.expectation_surprise_score != null && (
<Box
bg={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.15)" :
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.15)" : "rgba(66, 153, 225, 0.15)"}
borderWidth="1px"
borderColor={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.3)" :
event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.3)" : "rgba(66, 153, 225, 0.3)"}
borderRadius="md"
px={2}
py={1}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
超预期
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={event.expectation_surprise_score >= 60 ? "#fc8181" :
event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"}
>
{Math.round(event.expectation_surprise_score)}
</Text>
</Box>
)}
</HStack>
)}
</Box>
);
});
TimelineEventItem.displayName = "TimelineEventItem";
/** /**
* 单个主线卡片组件 - 支持懒加载 * 单个主线卡片组件 - 支持懒加载
*/ */
@@ -70,6 +245,23 @@ const MainlineCard = React.memo(
} }
}, [isExpanded]); }, [isExpanded]);
// 找出最大超额涨幅最高的事件HOT 事件)
const hotEvent = useMemo(() => {
if (!mainline.events || mainline.events.length === 0) return null;
let maxChange = -Infinity;
let hot = null;
mainline.events.forEach((event) => {
// 统一使用 related_max_chg最大超额
const change = event.related_max_chg ?? -Infinity;
if (change > maxChange) {
maxChange = change;
hot = event;
}
});
// 只有当最大超额 > 0 时才显示 HOT
return maxChange > 0 ? hot : null;
}, [mainline.events]);
// 当前显示的事件 // 当前显示的事件
const displayedEvents = useMemo(() => { const displayedEvents = useMemo(() => {
return mainline.events.slice(0, displayCount); return mainline.events.slice(0, displayCount);
@@ -101,8 +293,8 @@ const MainlineCard = React.memo(
borderColor={COLORS.cardBorderColor} borderColor={COLORS.cardBorderColor}
borderTopWidth="3px" borderTopWidth="3px"
borderTopColor={`${colorScheme}.500`} borderTopColor={`${colorScheme}.500`}
minW={isExpanded ? "280px" : "200px"} minW={isExpanded ? "320px" : "280px"}
maxW={isExpanded ? "320px" : "240px"} maxW={isExpanded ? "380px" : "320px"}
h="100%" h="100%"
display="flex" display="flex"
flexDirection="column" flexDirection="column"
@@ -114,6 +306,8 @@ const MainlineCard = React.memo(
}} }}
> >
{/* 卡片头部 */} {/* 卡片头部 */}
<Box flexShrink={0}>
{/* 第一行:概念名称 + 涨跌幅 + 事件数 */}
<Flex <Flex
align="center" align="center"
justify="space-between" justify="space-between"
@@ -123,9 +317,6 @@ const MainlineCard = React.memo(
onClick={onToggle} onClick={onToggle}
_hover={{ bg: COLORS.headerHoverBg }} _hover={{ bg: COLORS.headerHoverBg }}
transition="all 0.15s" transition="all 0.15s"
borderBottomWidth="1px"
borderBottomColor={COLORS.cardBorderColor}
flexShrink={0}
> >
<VStack align="start" spacing={0} flex={1} minW={0}> <VStack align="start" spacing={0} flex={1} minW={0}>
<HStack spacing={2} w="100%"> <HStack spacing={2} w="100%">
@@ -181,6 +372,67 @@ const MainlineCard = React.memo(
/> />
</Flex> </Flex>
{/* HOT 事件展示区域 */}
{hotEvent && (
<Box
px={3}
py={3}
bg="rgba(245, 101, 101, 0.1)"
borderBottomWidth="1px"
borderBottomColor={COLORS.cardBorderColor}
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
onEventSelect?.(hotEvent);
}}
_hover={{ bg: "rgba(245, 101, 101, 0.18)" }}
transition="all 0.15s"
>
{/* 第一行HOT 标签 + 最大超额 */}
<HStack spacing={2} mb={1.5}>
<Badge
bg="linear-gradient(135deg, #f56565 0%, #ed8936 100%)"
color="white"
fontSize="xs"
px={2}
py={0.5}
borderRadius="sm"
display="flex"
alignItems="center"
gap="3px"
fontWeight="bold"
>
<FireOutlined style={{ fontSize: 11 }} />
HOT
</Badge>
{/* 最大超额涨幅 */}
{hotEvent.related_max_chg != null && (
<Box
bg="rgba(239, 68, 68, 0.2)"
borderRadius="md"
px={2}
py={0.5}
>
<Text fontSize="xs" color="#fc8181" fontWeight="bold">
最大超额 +{hotEvent.related_max_chg.toFixed(2)}%
</Text>
</Box>
)}
</HStack>
{/* 第二行:标题 */}
<Text
fontSize="sm"
color={COLORS.textColor}
noOfLines={2}
lineHeight="1.5"
fontWeight="medium"
>
{hotEvent.title}
</Text>
</Box>
)}
</Box>
{/* 事件列表区域 */} {/* 事件列表区域 */}
{isExpanded ? ( {isExpanded ? (
<Box <Box
@@ -199,17 +451,15 @@ const MainlineCard = React.memo(
}, },
}} }}
> >
{/* 事件列表 - 单列布局 */} {/* 事件列表 - 卡片式 */}
<VStack spacing={2} align="stretch">
{displayedEvents.map((event) => ( {displayedEvents.map((event) => (
<MiniEventCard <TimelineEventItem
key={event.id} key={event.id}
event={event} event={event}
isSelected={selectedEvent?.id === event.id} isSelected={selectedEvent?.id === event.id}
onEventClick={onEventSelect} onEventClick={onEventSelect}
/> />
))} ))}
</VStack>
{/* 加载更多按钮 */} {/* 加载更多按钮 */}
{hasMore && ( {hasMore && (
@@ -221,7 +471,7 @@ const MainlineCard = React.memo(
isLoading={isLoadingMore} isLoading={isLoadingMore}
loadingText="加载中..." loadingText="加载中..."
w="100%" w="100%"
mt={2} mt={1}
_hover={{ bg: COLORS.headerHoverBg }} _hover={{ bg: COLORS.headerHoverBg }}
> >
加载更多 ({mainline.events.length - displayCount} ) 加载更多 ({mainline.events.length - displayCount} )
@@ -229,31 +479,21 @@ const MainlineCard = React.memo(
)} )}
</Box> </Box>
) : ( ) : (
/* 折叠时显示简要信息 */ /* 折叠时显示简要信息 - 卡片式 */
<Box px={3} py={2} flex={1} overflow="hidden"> <Box px={2} py={2} flex={1} overflow="hidden">
<VStack spacing={1} align="stretch"> {mainline.events.slice(0, 3).map((event) => (
{mainline.events.slice(0, 4).map((event) => ( <TimelineEventItem
<Text
key={event.id} key={event.id}
fontSize="xs" event={event}
color={COLORS.secondaryTextColor} isSelected={selectedEvent?.id === event.id}
noOfLines={1} onEventClick={onEventSelect}
cursor="pointer" />
_hover={{ color: COLORS.textColor }}
onClick={(e) => {
e.stopPropagation();
onEventSelect?.(event);
}}
>
{event.title}
</Text>
))} ))}
{mainline.events.length > 4 && ( {mainline.events.length > 3 && (
<Text fontSize="xs" color={COLORS.secondaryTextColor}> <Text fontSize="sm" color={COLORS.secondaryTextColor} textAlign="center" pt={1}>
... 还有 {mainline.events.length - 4} ... 还有 {mainline.events.length - 3}
</Text> </Text>
)} )}
</VStack>
</Box> </Box>
)} )}
</Box> </Box>
@@ -288,6 +528,8 @@ const MainlineTimelineViewComponent = forwardRef(
const [groupBy, setGroupBy] = useState("lv2"); const [groupBy, setGroupBy] = useState("lv2");
// 层级选项(从 API 获取) // 层级选项(从 API 获取)
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] }); const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
// 排序方式: 'event_count' | 'change_desc' | 'change_asc'
const [sortBy, setSortBy] = useState("event_count");
// 根据主线类型获取配色 // 根据主线类型获取配色
const getColorScheme = useCallback((lv2Name) => { const getColorScheme = useCallback((lv2Name) => {
@@ -372,16 +614,22 @@ const MainlineTimelineViewComponent = forwardRef(
const apiBase = getApiBase(); const apiBase = getApiBase();
const params = new URLSearchParams(); const params = new URLSearchParams();
// 添加筛选参数 // 添加筛选参数(主线模式支持时间范围筛选)
if (filters.recent_days) // 优先使用精确时间范围start_date/end_date其次使用 recent_days
if (filters.start_date) {
params.append("start_date", filters.start_date);
}
if (filters.end_date) {
params.append("end_date", filters.end_date);
}
if (filters.recent_days && !filters.start_date && !filters.end_date) {
// 只有在没有精确时间范围时才使用 recent_days
params.append("recent_days", filters.recent_days); params.append("recent_days", filters.recent_days);
if (filters.importance && filters.importance !== "all") }
params.append("importance", filters.importance);
// 添加分组方式参数 // 添加分组方式参数
params.append("group_by", groupBy); params.append("group_by", groupBy);
const url = `${apiBase}/api/events/mainline?${params.toString()}`; const url = `${apiBase}/api/events/mainline?${params.toString()}`;
console.log("[MainlineTimelineView] 🔄 请求主线数据:", url);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
@@ -389,32 +637,22 @@ const MainlineTimelineViewComponent = forwardRef(
} }
const result = await response.json(); const result = await response.json();
console.log("[MainlineTimelineView] 📦 响应数据:", {
success: result.success, // 兼容两种响应格式:{ success, data: {...} } 或 { success, mainlines, ... }
mainlineCount: result.data?.mainlines?.length, const responseData = result.data || result;
totalEvents: result.data?.total_events,
groupBy: result.data?.group_by,
});
if (result.success) { if (result.success) {
// 按事件数量从多到少排序 // 保存原始数据,排序在渲染时根据 sortBy 状态进行
const sortedMainlines = [...(result.data.mainlines || [])].sort( setMainlineData(responseData);
(a, b) => b.event_count - a.event_count
);
setMainlineData({
...result.data,
mainlines: sortedMainlines,
});
// 保存层级选项供下拉框使用 // 保存层级选项供下拉框使用
if (result.data.hierarchy_options) { if (responseData.hierarchy_options) {
setHierarchyOptions(result.data.hierarchy_options); setHierarchyOptions(responseData.hierarchy_options);
} }
// 初始化展开状态(默认全部展开) // 初始化展开状态(默认全部展开)
const initialExpanded = {}; const initialExpanded = {};
sortedMainlines.forEach((mainline) => { (responseData.mainlines || []).forEach((mainline) => {
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id; const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
initialExpanded[groupId] = true; initialExpanded[groupId] = true;
}); });
@@ -428,7 +666,7 @@ const MainlineTimelineViewComponent = forwardRef(
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [display, filters.recent_days, filters.importance, groupBy]); }, [display, filters.start_date, filters.end_date, filters.recent_days, groupBy]);
// 初始加载 & 筛选变化时刷新 // 初始加载 & 筛选变化时刷新
useEffect(() => { useEffect(() => {
@@ -466,6 +704,25 @@ const MainlineTimelineViewComponent = forwardRef(
[mainlineData] [mainlineData]
); );
// 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则)
const sortedMainlines = useMemo(() => {
const rawMainlines = mainlineData?.mainlines;
if (!rawMainlines) return [];
const sorted = [...rawMainlines];
switch (sortBy) {
case "change_desc":
// 按涨跌幅从高到低(涨幅大的在前)
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
case "change_asc":
// 按涨跌幅从低到高(跌幅大的在前)
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
case "event_count":
default:
// 按事件数量从多到少
return sorted.sort((a, b) => b.event_count - a.event_count);
}
}, [mainlineData?.mainlines, sortBy]);
// 渲染加载状态 // 渲染加载状态
if (loading) { if (loading) {
return ( return (
@@ -517,12 +774,14 @@ const MainlineTimelineViewComponent = forwardRef(
} }
const { const {
mainlines,
total_events, total_events,
mainline_count, mainline_count,
ungrouped_count, ungrouped_count,
} = mainlineData; } = mainlineData;
// 使用排序后的主线列表
const mainlines = sortedMainlines;
return ( return (
<Box <Box
display={display} display={display}
@@ -565,7 +824,7 @@ const MainlineTimelineViewComponent = forwardRef(
onChange={setGroupBy} onChange={setGroupBy}
size="small" size="small"
style={{ style={{
width: 200, width: 180,
backgroundColor: "transparent", backgroundColor: "transparent",
}} }}
popupClassName="dark-select-dropdown" popupClassName="dark-select-dropdown"
@@ -620,6 +879,26 @@ const MainlineTimelineViewComponent = forwardRef(
: []), : []),
]} ]}
/> />
{/* 排序方式选择器 */}
<Select
value={sortBy}
onChange={setSortBy}
size="small"
style={{
width: 140,
backgroundColor: "transparent",
}}
popupClassName="dark-select-dropdown"
dropdownStyle={{
backgroundColor: "#252a34",
borderColor: "#3a3f4b",
}}
options={[
{ value: "event_count", label: "按事件数量" },
{ value: "change_desc", label: "按涨幅↓" },
{ value: "change_asc", label: "按跌幅↓" },
]}
/>
<Tooltip label="全部展开"> <Tooltip label="全部展开">
<IconButton <IconButton
icon={<ChevronDownIcon />} icon={<ChevronDownIcon />}

View File

@@ -1,8 +1,64 @@
// 事件详情抽屉样式(从底部弹出) // 事件详情抽屉样式(从底部弹出)- 深色毛玻璃风格
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖 // 整体比背景亮一些,形成层次感
.event-detail-drawer {
// 深色主题变量 - 提亮以区分背景
@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 { .ant-drawer-title {
color: #1A202C; 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 React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Drawer } from 'antd'; import { Drawer, ConfigProvider, theme } from 'antd';
import { CloseOutlined } from '@ant-design/icons'; import { CloseOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice'; import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from '@components/EventDetailPanel'; import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import './EventDetailModal.less';
interface EventDetailModalProps { interface EventDetailModalProps {
/** 是否打开弹窗 */ /** 是否打开弹窗 */
@@ -15,8 +14,16 @@ interface EventDetailModalProps {
event: any; // TODO: 后续可替换为具体的 Event 类型 event: any; // TODO: 后续可替换为具体的 Event 类型
} }
// 深色主题颜色 - 比背景亮,形成层次感
const THEME = {
bg: '#2D3748',
headerBg: '#3D4A5C',
borderColor: 'rgba(255, 255, 255, 0.12)',
textColor: '#F7FAFC',
};
/** /**
* 事件详情抽屉组件(从底部弹出) * 事件详情抽屉组件(从底部弹出)- 深色风格
*/ */
const EventDetailModal: React.FC<EventDetailModalProps> = ({ const EventDetailModal: React.FC<EventDetailModalProps> = ({
open, open,
@@ -26,6 +33,27 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
const isMobile = useSelector(selectIsMobile); const isMobile = useSelector(selectIsMobile);
return ( return (
<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',
},
},
}}
>
<Drawer <Drawer
open={open} open={open}
onClose={onClose} onClose={onClose}
@@ -34,27 +62,33 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
width={isMobile ? '100%' : '70vw'} width={isMobile ? '100%' : '70vw'}
title={event?.title || '事件详情'} title={event?.title || '事件详情'}
destroyOnHidden destroyOnHidden
rootClassName="event-detail-drawer" closeIcon={<CloseOutlined />}
closeIcon={null}
extra={
<CloseOutlined
onClick={onClose}
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
/>
}
styles={{ styles={{
wrapper: isMobile ? {} : { wrapper: isMobile ? {} : {
maxWidth: 1400, maxWidth: 1400,
margin: '0 auto', 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', borderRadius: '16px 16px 0 0',
}, },
content: { borderRadius: '16px 16px 0 0' }, body: {
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' }, padding: 0,
body: { padding: 0, background: '#FFFFFF' }, background: THEME.bg,
},
}} }}
> >
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />} {event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Drawer> </Drawer>
</ConfigProvider>
); );
}; };

View File

@@ -163,13 +163,20 @@ const CompactSearchBox = ({
stockDisplayValueRef.current = null; 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)) { if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
let inferredKey = 'custom'; // 优先使用 time_filter_key来自 useEventFilters 的默认值)
let inferredKey = filters.time_filter_key || 'custom';
let inferredLabel = ''; 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') { if (filters.recent_days === '7') {
inferredKey = 'week'; inferredKey = 'week';
inferredLabel = '近一周'; inferredLabel = '近一周';
@@ -377,7 +384,12 @@ const CompactSearchBox = ({
const { range, type, label, key } = timeConfig; const { range, type, label, key } = timeConfig;
let params = {}; 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.recent_days = range;
params.start_date = ''; params.start_date = '';
params.end_date = ''; params.end_date = '';
@@ -524,7 +536,8 @@ const CompactSearchBox = ({
</div> </div>
</Flex> </Flex>
{/* 第二行:筛选条件 */} {/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */}
{mode !== 'mainline' && (
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
{/* 左侧筛选 */} {/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}> <Space size={isMobile ? 4 : 8}>
@@ -608,6 +621,7 @@ const CompactSearchBox = ({
</Button> </Button>
</Space> </Space>
</Flex> </Flex>
)}
</div> </div>
); );
}; };

View File

@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN'; import locale from 'antd/es/date-picker/locale/zh_CN';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import tradingDayUtils from '@utils/tradingDayUtils';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -83,28 +84,10 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
const yesterdayEnd = now.subtract(1, 'day').endOf('day'); const yesterdayEnd = now.subtract(1, 'day').endOf('day');
// 动态按钮配置(根据时段返回不同按钮数组) // 动态按钮配置(根据时段返回不同按钮数组)
// 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮
const dynamicButtonsMap = { const dynamicButtonsMap = {
'pre-market': [ 'pre-market': [], // 盘前:使用"当前交易日"即可
{
key: 'latest',
label: '最新',
range: [yesterday1500, today0930],
tooltip: '盘前资讯',
timeHint: `昨日 15:00 - 今日 09:30`,
color: 'purple',
type: 'precise'
}
],
'morning': [ 'morning': [
{
key: 'latest',
label: '最新',
range: [today0930, now],
tooltip: '早盘最新',
timeHint: `今日 09:30 - ${now.format('HH:mm')}`,
color: 'green',
type: 'precise'
},
{ {
key: 'intraday', key: 'intraday',
label: '盘中', label: '盘中',
@@ -115,27 +98,8 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
type: 'precise' type: 'precise'
} }
], ],
'lunch': [ 'lunch': [], // 午休:使用"当前交易日"即可
{
key: 'latest',
label: '最新',
range: [today1130, now],
tooltip: '午休时段',
timeHint: `今日 11:30 - ${now.format('HH:mm')}`,
color: 'orange',
type: 'precise'
}
],
'afternoon': [ 'afternoon': [
{
key: 'latest',
label: '最新',
range: [today1300, now],
tooltip: '午盘最新',
timeHint: `今日 13:00 - ${now.format('HH:mm')}`,
color: 'green',
type: 'precise'
},
{ {
key: 'intraday', key: 'intraday',
label: '盘中', label: '盘中',
@@ -155,21 +119,35 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
type: 'precise' type: 'precise'
} }
], ],
'after-hours': [ 'after-hours': [] // 盘后:使用"当前交易日"即可
{
key: 'latest',
label: '最新',
range: [today1500, now],
tooltip: '盘后最新',
timeHint: `今日 15:00 - ${now.format('HH:mm')}`,
color: 'red',
type: 'precise'
}
]
}; };
// 获取上一个交易日(使用 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 = [ 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', key: 'morning-fixed',
label: '早盘', label: '早盘',
@@ -214,6 +192,15 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
timeHint: '过去30天', timeHint: '过去30天',
color: 'volcano', color: 'volcano',
type: 'recent_days' 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 { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants'; import { RETENTION_EVENTS } from '../../../lib/constants';
import { getEventDetailUrl } from '@/utils/idEncoder'; import { getEventDetailUrl } from '@/utils/idEncoder';
import tradingDayUtils from '@utils/tradingDayUtils';
import dayjs from 'dayjs';
/** /**
* 事件筛选逻辑 Hook * 事件筛选逻辑 Hook
@@ -22,16 +24,43 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
// 筛选参数状态 - 初始化时从URL读取之后只用本地状态 // 筛选参数状态 - 初始化时从URL读取之后只用本地状态
const [filters, setFilters] = useState(() => { 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 { return {
sort: searchParams.get('sort') || 'new', sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all', importance: searchParams.get('importance') || 'all',
q: searchParams.get('q') || '', q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '', industry_code: searchParams.get('industry_code') || '',
// 时间筛选参数(从 TradingTimeFilter 传递) // 时间筛选参数(从 TradingTimeFilter 传递)
// 默认显示近一周数据recent_days=7 // 默认显示当前交易日数据上一交易日15:00 - 现在
start_date: searchParams.get('start_date') || '', start_date: searchParams.get('start_date') || defaultTimeRange.start_date,
end_date: searchParams.get('end_date') || '', end_date: searchParams.get('end_date') || defaultTimeRange.end_date,
recent_days: searchParams.get('recent_days') || '7', // 默认近一周 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) page: parseInt(searchParams.get('page') || '1', 10)
}; };
}); });

View File

@@ -49,7 +49,7 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isAc
}; };
if (loading) { if (loading) {
return <LoadingState message="加载公告数据..." />; return <LoadingState variant="skeleton" skeletonType="list" skeletonCount={5} />;
} }
return ( return (

View File

@@ -27,7 +27,7 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive }); const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
if (loading) { if (loading) {
return <LoadingState message="加载披露日程..." />; return <LoadingState variant="skeleton" skeletonType="grid" skeletonCount={4} />;
} }
if (disclosureSchedule.length === 0) { if (disclosureSchedule.length === 0) {

View File

@@ -1,22 +1,110 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx // src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
// 复用的加载状态组件 // 复用的加载状态组件 - 支持骨架屏
import React from "react"; import React, { memo } from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react"; import {
Center,
VStack,
Spinner,
Text,
Box,
Skeleton,
SimpleGrid,
HStack,
} from "@chakra-ui/react";
import { THEME } from "../config"; import { THEME } from "../config";
// 骨架屏颜色配置
const SKELETON_COLORS = {
startColor: "rgba(26, 32, 44, 0.6)",
endColor: "rgba(212, 175, 55, 0.2)",
};
interface LoadingStateProps { interface LoadingStateProps {
message?: string; message?: string;
height?: 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 = "加载中...", message = "加载中...",
height = "200px", 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 ( return (
<Center h={height}> <Center h={height}>
<VStack> <VStack>
@@ -27,6 +115,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
</VStack> </VStack>
</Center> </Center>
); );
}; });
LoadingState.displayName = "LoadingState";
export default LoadingState; export default LoadingState;

View File

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

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts // src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab // 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Announcement } from "../types"; import type { Announcement } from "../types";
@@ -39,7 +39,11 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
const [announcements, setAnnouncements] = useState<Announcement[]>([]); const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCode 和 refreshKey
const lastStockCodeRef = useRef<string | undefined>(undefined);
const lastRefreshKeyRef = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
// 只有 enabled 且有 stockCode 时才请求 // 只有 enabled 且有 stockCode 时才请求
@@ -48,6 +52,26 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
return; 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(); const controller = new AbortController();
const loadData = async () => { const loadData = async () => {
@@ -66,7 +90,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
setError("加载公告数据失败"); setError("加载公告数据失败");
} }
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} catch (err: any) { } catch (err: any) {
// 请求被取消时,不更新任何状态 // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") { if (err.name === "CanceledError") {
@@ -75,7 +99,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
logger.error("useAnnouncementsData", "loadData", err, { stockCode }); logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} }
}; };
@@ -83,7 +107,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA
return () => controller.abort(); return () => controller.abort();
}, [stockCode, enabled, refreshKey]); }, [stockCode, enabled, refreshKey]);
const isLoading = loading || (enabled && !hasLoaded && !error); const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { announcements, loading: isLoading, error }; return { announcements, loading: isLoading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts // src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab // 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Branch } from "../types"; import type { Branch } from "../types";
@@ -36,7 +36,10 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
const [branches, setBranches] = useState<Branch[]>([]); const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!enabled || !stockCode) { if (!enabled || !stockCode) {
@@ -44,6 +47,18 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
return; return;
} }
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController(); const controller = new AbortController();
const loadData = async () => { const loadData = async () => {
@@ -62,7 +77,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
setError("加载分支机构数据失败"); setError("加载分支机构数据失败");
} }
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} catch (err: any) { } catch (err: any) {
// 请求被取消时,不更新任何状态 // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") { if (err.name === "CanceledError") {
@@ -71,7 +86,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
logger.error("useBranchesData", "loadData", err, { stockCode }); logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} }
}; };
@@ -79,7 +94,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat
return () => controller.abort(); return () => controller.abort();
}, [stockCode, enabled]); }, [stockCode, enabled]);
const isLoading = loading || (enabled && !hasLoaded && !error); const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { branches, loading: isLoading, error }; return { branches, loading: isLoading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts // src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab // 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types"; import type { DisclosureSchedule } from "../types";
@@ -36,7 +36,10 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]); const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
// 只有 enabled 且有 stockCode 时才请求 // 只有 enabled 且有 stockCode 时才请求
@@ -45,6 +48,18 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
return; return;
} }
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController(); const controller = new AbortController();
const loadData = async () => { const loadData = async () => {
@@ -63,7 +78,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
setError("加载披露日程数据失败"); setError("加载披露日程数据失败");
} }
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} catch (err: any) { } catch (err: any) {
// 请求被取消时,不更新任何状态 // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") { if (err.name === "CanceledError") {
@@ -72,7 +87,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
logger.error("useDisclosureData", "loadData", err, { stockCode }); logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} }
}; };
@@ -80,7 +95,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos
return () => controller.abort(); return () => controller.abort();
}, [stockCode, enabled]); }, [stockCode, enabled]);
const isLoading = loading || (enabled && !hasLoaded && !error); const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { disclosureSchedule, loading: isLoading, error }; return { disclosureSchedule, loading: isLoading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts // src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab // 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Management } from "../types"; import type { Management } from "../types";
@@ -36,7 +36,10 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
const [management, setManagement] = useState<Management[]>([]); const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
// 只有 enabled 且有 stockCode 时才请求 // 只有 enabled 且有 stockCode 时才请求
@@ -45,6 +48,18 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
return; return;
} }
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController(); const controller = new AbortController();
const loadData = async () => { const loadData = async () => {
@@ -63,7 +78,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
setError("加载管理团队数据失败"); setError("加载管理团队数据失败");
} }
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} catch (err: any) { } catch (err: any) {
// 请求被取消时,不更新任何状态 // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") { if (err.name === "CanceledError") {
@@ -72,7 +87,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
logger.error("useManagementData", "loadData", err, { stockCode }); logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} }
}; };
@@ -82,7 +97,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem
// 派生 loading 状态enabled 但尚未完成首次加载时,视为 loading // 派生 loading 状态enabled 但尚未完成首次加载时,视为 loading
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁 // 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
const isLoading = loading || (enabled && !hasLoaded && !error); const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { management, loading: isLoading, error }; return { management, loading: isLoading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts // src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab // 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { ActualControl, Concentration, Shareholder } from "../types"; import type { ActualControl, Concentration, Shareholder } from "../types";
@@ -42,7 +42,10 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]); const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false); // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求
const hasLoadedRef = useRef(false);
// 记录上次加载的 stockCodestockCode 变化时需要重新加载
const lastStockCodeRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
// 只有 enabled 且有 stockCode 时才请求 // 只有 enabled 且有 stockCode 时才请求
@@ -51,6 +54,18 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
return; return;
} }
// stockCode 变化时重置加载状态
if (lastStockCodeRef.current !== stockCode) {
hasLoadedRef.current = false;
lastStockCodeRef.current = stockCode;
}
// 如果已经加载过数据不再重新请求Tab 切换回来时保持缓存)
if (hasLoadedRef.current) {
setLoading(false);
return;
}
const controller = new AbortController(); const controller = new AbortController();
const loadData = async () => { const loadData = async () => {
@@ -75,7 +90,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data); if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data); if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} catch (err: any) { } catch (err: any) {
// 请求被取消时,不更新任何状态 // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") { if (err.name === "CanceledError") {
@@ -84,7 +99,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
logger.error("useShareholderData", "loadData", err, { stockCode }); logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败"); setError("加载股权结构数据失败");
setLoading(false); setLoading(false);
setHasLoaded(true); hasLoadedRef.current = true;
} }
}; };
@@ -92,7 +107,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare
return () => controller.abort(); return () => controller.abort();
}, [stockCode, enabled]); }, [stockCode, enabled]);
const isLoading = loading || (enabled && !hasLoaded && !error); const isLoading = loading || (enabled && !hasLoadedRef.current && !error);
return { return {
actualControl, actualControl,

View File

@@ -136,5 +136,5 @@ const MarketDataSkeleton: React.FC = memo(() => (
MarketDataSkeleton.displayName = 'MarketDataSkeleton'; MarketDataSkeleton.displayName = 'MarketDataSkeleton';
export { MarketDataSkeleton }; export { MarketDataSkeleton, SummaryCardSkeleton };
export default MarketDataSkeleton; export default MarketDataSkeleton;

View File

@@ -5,4 +5,4 @@ export { default as ThemedCard } from './ThemedCard';
export { default as MarkdownRenderer } from './MarkdownRenderer'; export { default as MarkdownRenderer } from './MarkdownRenderer';
export { default as StockSummaryCard } from './StockSummaryCard'; export { default as StockSummaryCard } from './StockSummaryCard';
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal'; export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
export { MarketDataSkeleton } from './MarketDataSkeleton'; export { MarketDataSkeleton, SummaryCardSkeleton } from './MarketDataSkeleton';

View File

@@ -33,8 +33,9 @@ export const useMarketData = (
period: number = DEFAULT_PERIOD period: number = DEFAULT_PERIOD
): UseMarketDataReturn => { ): UseMarketDataReturn => {
// 主数据状态 // 主数据状态
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [tradeLoading, setTradeLoading] = useState(false); const [tradeLoading, setTradeLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [summary, setSummary] = useState<MarketSummary | null>(null); const [summary, setSummary] = useState<MarketSummary | null>(null);
const [tradeData, setTradeData] = useState<TradeDayData[]>([]); const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
const [fundingData, setFundingData] = useState<FundingDayData[]>([]); const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
@@ -153,15 +154,17 @@ export const useMarketData = (
if (loadedTradeData.length > 0) { if (loadedTradeData.length > 0) {
loadRiseAnalysis(loadedTradeData); loadRiseAnalysis(loadedTradeData);
} }
} catch (error) {
// 取消请求不作为错误处理
if (isCancelError(error)) return;
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
} finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoading(false); setLoading(false);
setHasLoaded(true);
} catch (error) {
// 请求被取消时,不更新任何状态
if (isCancelError(error)) {
return;
} }
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
setLoading(false);
setHasLoaded(true);
} }
}, [stockCode, period, loadRiseAnalysis]); }, [stockCode, period, loadRiseAnalysis]);
@@ -363,8 +366,11 @@ export const useMarketData = (
}; };
}, []); }, []);
// 派生 loading 状态stockCode 存在但尚未完成首次加载时,视为 loading
const isLoading = loading || (!!stockCode && !hasLoaded);
return { return {
loading, loading: isLoading,
tradeLoading, tradeLoading,
summary, summary,
tradeData, tradeData,

View File

@@ -22,6 +22,7 @@ import { useMarketData } from './hooks/useMarketData';
import { import {
ThemedCard, ThemedCard,
StockSummaryCard, StockSummaryCard,
SummaryCardSkeleton,
AnalysisModal, AnalysisModal,
AnalysisContent, AnalysisContent,
} from './components'; } from './components';
@@ -89,13 +90,12 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
} }
}, [propStockCode, stockCode]); }, [propStockCode, stockCode]);
// 首次渲染时加载默认 Tab融资融券的数据 // 首次挂载时加载默认 Tab融资融券的数据
// 注意SubTabContainer 的 onChange 只在切换时触发,首次渲染不会触发
useEffect(() => { useEffect(() => {
// 默认 Tab 是融资融券index 0
if (activeTab === 0) {
loadDataByType('funding'); loadDataByType('funding');
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadDataByType, activeTab]); }, []); // 只在首次挂载时执行
// 处理图表点击事件 // 处理图表点击事件
const handleChartClick = useCallback( const handleChartClick = useCallback(
@@ -137,8 +137,8 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}> <Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
<Container maxW="container.xl" py={4}> <Container maxW="container.xl" py={4}>
<VStack align="stretch" spacing={4}> <VStack align="stretch" spacing={4}>
{/* 股票概览 */} {/* 股票概览 - 未加载时显示骨架屏占位,避免布局跳动 */}
{summary && <StockSummaryCard summary={summary} theme={theme} />} {summary ? <StockSummaryCard summary={summary} theme={theme} /> : <SummaryCardSkeleton />}
{/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */} {/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */}
<TradeDataPanel <TradeDataPanel

View File

@@ -2,11 +2,10 @@ import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents'; import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
import RiskDisclaimer from '../../components/RiskDisclaimer'; import RiskDisclaimer from '../../components/RiskDisclaimer';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
// 使用新的公共日历组件
import { BaseCalendar } from '@components/Calendar';
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@@ -198,93 +197,19 @@ const ConceptTimelineModal = ({
} }
}; };
// 转换时间轴数据为日历事件格式(一天拆分为多个独立事件 // 按日期索引的事件数据(用于日历单元格渲染
const calendarEvents = useMemo(() => { const eventsByDate = useMemo(() => {
const events = []; const map = {};
timelineData.forEach(item => { timelineData.forEach(item => {
const priceInfo = getPriceInfo(item.price); map[item.date] = item;
const newsCount = (item.events || []).filter(e => e.type === 'news').length;
const reportCount = (item.events || []).filter(e => e.type === 'report').length;
const hasPriceData = item.price && item.price.avg_change_pct !== null;
// 如果有新闻,添加新闻事件
if (newsCount > 0) {
events.push({
id: `${item.date}-news`,
title: `📰 ${newsCount} 条新闻`,
date: item.date,
start: item.date,
backgroundColor: '#9F7AEA',
borderColor: '#9F7AEA',
extendedProps: {
eventType: 'news',
count: newsCount,
originalData: item,
}
}); });
} return map;
// 如果有研报,添加研报事件
if (reportCount > 0) {
events.push({
id: `${item.date}-report`,
title: `📊 ${reportCount} 篇研报`,
date: item.date,
start: item.date,
backgroundColor: '#805AD5',
borderColor: '#805AD5',
extendedProps: {
eventType: 'report',
count: reportCount,
originalData: item,
}
});
}
// 如果有价格数据,添加价格事件
if (hasPriceData) {
const changePercent = item.price.avg_change_pct;
const isSignificantRise = changePercent >= 3; // 涨幅 >= 3% 为重大利好
let bgColor = '#e2e8f0';
let title = priceInfo.text;
if (priceInfo.color === 'red') {
if (isSignificantRise) {
// 涨幅 >= 3%,使用醒目的橙红色 + 火焰图标
bgColor = '#F56565'; // 更深的红色
title = `🔥 ${priceInfo.text}`;
} else {
bgColor = '#FC8181'; // 普通红色(上涨)
}
} else if (priceInfo.color === 'green') {
bgColor = '#68D391'; // 绿色(下跌)
}
events.push({
id: `${item.date}-price`,
title: title,
date: item.date,
start: item.date,
backgroundColor: bgColor,
borderColor: isSignificantRise ? '#C53030' : bgColor, // 深红色边框强调
extendedProps: {
eventType: 'price',
priceInfo,
originalData: item,
isSignificantRise, // 标记重大涨幅
}
});
}
});
return events;
}, [timelineData]); }, [timelineData]);
// 处理日期点击 // 处理日期选择(点击日期单元格)
const handleDateClick = (info) => { const handleDateSelect = (date) => {
const clickedDate = info.dateStr; const clickedDate = date.format('YYYY-MM-DD');
const dateData = timelineData.find(item => item.date === clickedDate); const dateData = eventsByDate[clickedDate];
if (dateData) { if (dateData) {
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
@@ -296,16 +221,128 @@ const ConceptTimelineModal = ({
} }
}; };
// 处理事件点击 // 自定义日期单元格内容渲染
const handleEventClick = (info) => { const renderCellContent = (date) => {
// 从事件的 extendedProps 中获取原始数据 const dateStr = date.format('YYYY-MM-DD');
const dateData = info.event.extendedProps?.originalData; const item = eventsByDate[dateStr];
if (dateData) { if (!item) return null;
setSelectedDate(dateData.date);
setSelectedDateData(dateData); const priceInfo = getPriceInfo(item.price);
onDateDetailOpen(); const newsCount = (item.events || []).filter(e => e.type === 'news').length;
const reportCount = (item.events || []).filter(e => e.type === 'report').length;
const hasPriceData = item.price && item.price.avg_change_pct !== null;
const events = [];
// 新闻事件
if (newsCount > 0) {
events.push(
<HStack
key="news"
spacing={1}
fontSize="10px"
color="#9F7AEA"
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
bg="rgba(159, 122, 234, 0.2)"
_hover={{ bg: 'rgba(159, 122, 234, 0.3)' }}
w="100%"
overflow="hidden"
>
<Text fontWeight="600" fontSize="10px" isTruncated flex="1" minW={0}>
📰 {newsCount} 条新闻
</Text>
</HStack>
);
} }
// 研报事件
if (reportCount > 0) {
events.push(
<HStack
key="report"
spacing={1}
fontSize="10px"
color="#805AD5"
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
bg="rgba(128, 90, 213, 0.2)"
_hover={{ bg: 'rgba(128, 90, 213, 0.3)' }}
w="100%"
overflow="hidden"
>
<Text fontWeight="600" fontSize="10px" isTruncated flex="1" minW={0}>
📊 {reportCount} 篇研报
</Text>
</HStack>
);
}
// 涨跌数据
if (hasPriceData) {
const changePercent = item.price.avg_change_pct;
const isSignificantRise = changePercent >= 3;
let bgColor = 'rgba(226, 232, 240, 0.2)';
let textColor = '#e2e8f0';
let title = priceInfo.text;
if (priceInfo.color === 'red') {
if (isSignificantRise) {
bgColor = 'rgba(245, 101, 101, 0.3)';
textColor = '#F56565';
title = `🔥 ${priceInfo.text}`;
} else {
bgColor = 'rgba(252, 129, 129, 0.2)';
textColor = '#FC8181';
}
} else if (priceInfo.color === 'green') {
bgColor = 'rgba(104, 211, 145, 0.2)';
textColor = '#68D391';
}
events.push(
<HStack
key="price"
spacing={1}
fontSize="10px"
color={textColor}
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
bg={bgColor}
border={isSignificantRise ? '1px solid #C53030' : 'none'}
_hover={{ opacity: 0.8 }}
w="100%"
overflow="hidden"
>
<Text fontWeight="bold" fontSize="10px" isTruncated flex="1" minW={0}>
{title}
</Text>
</HStack>
);
}
// 最多显示 3 个事件,超出显示 "更多"
const maxDisplay = 3;
const displayEvents = events.slice(0, maxDisplay);
const remainingCount = events.length - maxDisplay;
return (
<VStack spacing={0.5} align="stretch" w="100%" mt={1}>
{displayEvents}
{remainingCount > 0 && (
<Text fontSize="9px" color="whiteAlpha.600" px={1}>
+{remainingCount} 更多
</Text>
)}
</VStack>
);
}; };
// 获取时间轴数据 // 获取时间轴数据
@@ -833,7 +870,7 @@ const ConceptTimelineModal = ({
</HStack> </HStack>
</Flex> </Flex>
{/* FullCalendar 日历组件 */} {/* Ant Design 日历组件 */}
<Box <Box
height={{ base: '500px', md: '700px' }} height={{ base: '500px', md: '700px' }}
bg="rgba(15, 23, 42, 0.6)" bg="rgba(15, 23, 42, 0.6)"
@@ -841,129 +878,12 @@ const ConceptTimelineModal = ({
border="1px solid" border="1px solid"
borderColor="whiteAlpha.100" borderColor="whiteAlpha.100"
p={{ base: 1, md: 4 }} p={{ base: 1, md: 4 }}
sx={{
// FullCalendar 深色主题样式定制
'.fc': {
height: '100%',
},
'.fc-header-toolbar': {
marginBottom: { base: '0.5rem', md: '1.5rem' },
padding: { base: '0 4px', md: '0' },
flexWrap: 'nowrap',
gap: { base: '4px', md: '8px' },
},
'.fc-toolbar-chunk': {
display: 'flex',
alignItems: 'center',
},
'.fc-toolbar-title': {
fontSize: { base: '1rem', md: '1.5rem' },
fontWeight: 'bold',
color: 'white',
},
'.fc-button': {
backgroundColor: 'rgba(139, 92, 246, 0.6)',
borderColor: 'rgba(139, 92, 246, 0.8)',
color: 'white',
padding: { base: '4px 8px', md: '6px 12px' },
fontSize: { base: '12px', md: '14px' },
'&:hover': {
backgroundColor: 'rgba(139, 92, 246, 0.8)',
borderColor: 'rgba(139, 92, 246, 1)',
},
'&:active, &:focus': {
backgroundColor: 'rgba(139, 92, 246, 1)',
borderColor: 'rgba(139, 92, 246, 1)',
boxShadow: 'none',
},
},
'.fc-button-active': {
backgroundColor: 'rgba(139, 92, 246, 1)',
borderColor: 'rgba(139, 92, 246, 1)',
},
// 深色主题 - 表格边框和背景
'.fc-theme-standard td, .fc-theme-standard th': {
borderColor: 'rgba(255, 255, 255, 0.1)',
},
'.fc-theme-standard .fc-scrollgrid': {
borderColor: 'rgba(255, 255, 255, 0.1)',
},
'.fc-col-header-cell': {
backgroundColor: 'rgba(15, 23, 42, 0.8)',
},
'.fc-col-header-cell-cushion': {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: { base: '0.75rem', md: '0.875rem' },
padding: { base: '4px 2px', md: '8px' },
},
'.fc-daygrid-day': {
cursor: 'pointer',
transition: 'all 0.2s',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: 'rgba(139, 92, 246, 0.2)',
},
},
'.fc-daygrid-day-number': {
color: 'rgba(255, 255, 255, 0.9)',
padding: { base: '2px', md: '4px' },
fontSize: { base: '0.75rem', md: '0.875rem' },
},
'.fc-day-today': {
backgroundColor: 'rgba(139, 92, 246, 0.15) !important',
},
'.fc-day-other .fc-daygrid-day-number': {
color: 'rgba(255, 255, 255, 0.4)',
},
'.fc-event': {
cursor: 'pointer',
border: 'none',
padding: { base: '1px 2px', md: '2px 4px' },
fontSize: { base: '0.65rem', md: '0.75rem' },
fontWeight: 'bold',
borderRadius: '4px',
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
},
},
'.fc-daygrid-event-harness': {
marginBottom: { base: '1px', md: '2px' },
},
'.fc-more-link': {
color: 'rgba(255, 255, 255, 0.8)',
},
// H5 端隐藏事件文字,只显示色块
'@media (max-width: 768px)': {
'.fc-event-title': {
fontSize: '0.6rem',
},
},
}}
> >
<FullCalendar <BaseCalendar
plugins={[dayGridPlugin, interactionPlugin]} onSelect={handleDateSelect}
initialView="dayGridMonth" cellRender={renderCellContent}
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: '',
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%" height="100%"
dayMaxEvents={3} showToolbar={true}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周',
}}
eventDisplay="block"
displayEventTime={false}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -1,610 +0,0 @@
// src/views/Dashboard/Center.js
import React, { useEffect, useState, useCallback } from 'react';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
import {
Box,
Flex,
Grid,
SimpleGrid,
Stack,
Text,
Badge,
Button,
VStack,
HStack,
Card,
CardHeader,
CardBody,
Heading,
useColorModeValue,
Icon,
IconButton,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Divider,
Tag,
TagLabel,
TagLeftIcon,
Wrap,
WrapItem,
Avatar,
Tooltip,
Progress,
useToast,
LinkBox,
LinkOverlay,
Spinner,
Center,
Image,
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import {
FiTrendingUp,
FiEye,
FiMessageSquare,
FiThumbsUp,
FiClock,
FiCalendar,
FiRefreshCw,
FiTrash2,
FiExternalLink,
FiPlus,
FiBarChart2,
FiStar,
FiActivity,
FiAlertCircle,
FiUsers,
} from 'react-icons/fi';
import MyFutureEvents from './components/MyFutureEvents';
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
import { getEventDetailUrl } from '@/utils/idEncoder';
export default function CenterDashboard() {
const { user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
// ⚡ 提取 userId 为独立变量
const userId = user?.id;
// 🎯 初始化Dashboard埋点Hook
const dashboardEvents = useDashboardEvents({
pageType: 'center',
navigate
});
// 颜色主题
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');
const [watchlist, setWatchlist] = useState([]);
const [realtimeQuotes, setRealtimeQuotes] = useState({});
const [followingEvents, setFollowingEvents] = useState([]);
const [eventComments, setEventComments] = useState([]);
const [loading, setLoading] = useState(true);
const [quotesLoading, setQuotesLoading] = useState(false);
const loadData = useCallback(async () => {
try {
const base = getApiBase();
const ts = Date.now();
const [w, e, c] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
]);
const jw = await w.json();
const je = await e.json();
const jc = await c.json();
if (jw.success) {
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
setWatchlist(watchlistData);
// 🎯 追踪自选股列表查看
if (watchlistData.length > 0) {
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
}
// 加载实时行情
if (jw.data && jw.data.length > 0) {
loadRealtimeQuotes();
}
}
if (je.success) {
const eventsData = Array.isArray(je.data) ? je.data : [];
setFollowingEvents(eventsData);
// 🎯 追踪关注的事件列表查看
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
}
if (jc.success) {
const commentsData = Array.isArray(jc.data) ? jc.data : [];
setEventComments(commentsData);
// 🎯 追踪评论列表查看
dashboardEvents.trackCommentsViewed(commentsData.length);
}
} catch (err) {
logger.error('Center', 'loadData', err, {
userId,
timestamp: new Date().toISOString()
});
} finally {
setLoading(false);
}
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
// 加载实时行情
const loadRealtimeQuotes = useCallback(async () => {
try {
setQuotesLoading(true);
const base = getApiBase();
const response = await fetch(base + '/api/account/watchlist/realtime', {
credentials: 'include',
cache: 'no-store'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const quotesMap = {};
data.data.forEach(item => {
quotesMap[item.stock_code] = item;
});
setRealtimeQuotes(quotesMap);
}
}
} catch (error) {
logger.error('Center', 'loadRealtimeQuotes', error, {
userId: user?.id,
watchlistLength: watchlist.length
});
} finally {
setQuotesLoading(false);
}
}, []);
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
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');
}
};
// 格式化数字
const formatNumber = (num) => {
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();
};
// 获取事件热度颜色
const getHeatColor = (score) => {
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
const hasLoadedRef = React.useRef(false);
useEffect(() => {
const isOnCenterPage = location.pathname.includes('/home/center');
// 首次进入页面且有用户时加载数据
if (user && isOnCenterPage && !hasLoadedRef.current) {
console.log('[Center] 🚀 首次加载数据');
hasLoadedRef.current = true;
loadData();
}
const onVis = () => {
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
console.log('[Center] 👁️ visibilitychange 触发 loadData');
loadData();
}
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [userId, location.pathname, loadData, user]);
// 当用户登出再登入userId 变化)时,重置加载标记
useEffect(() => {
if (!user) {
hasLoadedRef.current = false;
}
}, [user]);
// 定时刷新实时行情(每分钟一次)
useEffect(() => {
if (watchlist.length > 0) {
const interval = setInterval(() => {
loadRealtimeQuotes();
}, 60000); // 60秒刷新一次
return () => clearInterval(interval);
}
}, [watchlist.length, loadRealtimeQuotes]);
// 渲染加载状态
if (loading) {
return (
<Center h="60vh">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color={secondaryText}>加载个人中心数据...</Text>
</VStack>
</Center>
);
}
return (
<Box bg={sectionBg} minH="100vh" overflowX="hidden">
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
{/* 主要内容区域 */}
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
{/* 左列:自选股票 */}
<VStack spacing={6} align="stretch" minW={0}>
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
<Badge colorScheme="blue" variant="subtle">
{watchlist.length}
</Badge>
{quotesLoading && <Spinner size="sm" color="blue.500" />}
</HStack>
<IconButton
icon={<FiPlus />}
variant="ghost"
size="sm"
onClick={() => navigate('/stocks')}
aria-label="添加自选股"
/>
</Flex>
</CardHeader>
<CardBody pt={0} flex="1" overflowY="auto">
{watchlist.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiBarChart2} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无自选股
</Text>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/stocks')}
>
添加自选股
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={2}>
{watchlist.slice(0, 10).map((stock) => (
<LinkBox
key={stock.stock_code}
p={3}
borderRadius="md"
_hover={{ bg: hoverBg }}
transition="all 0.2s"
cursor="pointer"
>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<LinkOverlay
as={Link}
to={`/company/${stock.stock_code}`}
>
<Text fontWeight="medium" fontSize="sm">
{stock.stock_name || stock.stock_code}
</Text>
</LinkOverlay>
<HStack spacing={2}>
<Badge variant="subtle" fontSize="xs">
{stock.stock_code}
</Badge>
{realtimeQuotes[stock.stock_code] ? (
<Badge
colorScheme={realtimeQuotes[stock.stock_code].change_percent > 0 ? 'red' : 'green'}
fontSize="xs"
>
{realtimeQuotes[stock.stock_code].change_percent > 0 ? '+' : ''}
{realtimeQuotes[stock.stock_code].change_percent.toFixed(2)}%
</Badge>
) : stock.change_percent ? (
<Badge
colorScheme={stock.change_percent > 0 ? 'red' : 'green'}
fontSize="xs"
>
{stock.change_percent > 0 ? '+' : ''}
{stock.change_percent}%
</Badge>
) : null}
</HStack>
</VStack>
<VStack align="end" spacing={0}>
<Text fontWeight="bold" fontSize="sm">
{realtimeQuotes[stock.stock_code]?.current_price?.toFixed(2) || stock.current_price || '--'}
</Text>
<Text fontSize="xs" color={secondaryText}>
{realtimeQuotes[stock.stock_code]?.update_time || stock.industry || '未分类'}
</Text>
</VStack>
</HStack>
</LinkBox>
))}
{watchlist.length > 10 && (
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/stocks')}
>
查看全部 ({watchlist.length})
</Button>
)}
</VStack>
)}
</CardBody>
</Card>
</VStack>
{/* 中列:关注事件 */}
<VStack spacing={6} align="stretch" minW={0}>
{/* 关注事件 */}
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
<Badge colorScheme="yellow" variant="subtle">
{followingEvents.length}
</Badge>
</HStack>
<IconButton
icon={<FiPlus />}
variant="ghost"
size="sm"
onClick={() => navigate('/community')}
aria-label="添加关注事件"
/>
</Flex>
</CardHeader>
<CardBody pt={0} flex="1" overflowY="auto">
{followingEvents.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiActivity} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无关注事件
</Text>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/community')}
>
探索事件
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={3}>
{followingEvents.slice(0, 5).map((event) => (
<LinkBox
key={event.id}
p={4}
borderRadius="lg"
border="1px"
borderColor={borderColor}
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
<LinkOverlay
as={Link}
to={getEventDetailUrl(event.id)}
>
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
{event.title}
</Text>
</LinkOverlay>
{/* 事件标签 */}
{event.tags && event.tags.length > 0 && (
<Wrap>
{event.tags.slice(0, 3).map((tag, idx) => (
<WrapItem key={idx}>
<Tag size="sm" variant="subtle" colorScheme="blue">
<TagLabel>{tag}</TagLabel>
</Tag>
</WrapItem>
))}
</Wrap>
)}
{/* 事件统计 */}
<HStack spacing={4} fontSize="sm" color={secondaryText}>
{event.related_avg_chg !== undefined && event.related_avg_chg !== null && (
<Badge
colorScheme={event.related_avg_chg > 0 ? 'red' : 'green'}
variant="subtle"
>
平均超额 {event.related_avg_chg > 0 ? '+' : ''}{Number(event.related_avg_chg).toFixed(2)}%
</Badge>
)}
<HStack spacing={1}>
<Icon as={FiUsers} />
<Text>{event.follower_count || 0} 关注</Text>
</HStack>
</HStack>
{/* 事件信息 */}
<Flex justify="space-between" align="center">
<HStack spacing={2} fontSize="xs" color={secondaryText}>
<Avatar
size="xs"
name={event.creator?.username || '系统'}
src={event.creator?.avatar_url}
/>
<Text>{event.creator?.username || '系统'}</Text>
<Text>·</Text>
<Text>{formatDate(event.created_at)}</Text>
</HStack>
{event.exceed_expectation_score && (
<Badge
colorScheme={event.exceed_expectation_score > 70 ? 'red' : 'orange'}
variant="solid"
fontSize="xs"
>
超预期 {event.exceed_expectation_score}
</Badge>
)}
</Flex>
</VStack>
</LinkBox>
))}
{followingEvents.length > 5 && (
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/community')}
>
查看全部 ({followingEvents.length})
</Button>
)}
</VStack>
)}
</CardBody>
</Card>
</VStack>
{/* 右列:我的评论 */}
<VStack spacing={6} align="stretch" minW={0}>
{/* 我的评论 */}
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
<CardHeader pb={{ base: 2, md: 4 }}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
<Badge colorScheme="purple" variant="subtle">
{eventComments.length}
</Badge>
</HStack>
</Flex>
</CardHeader>
<CardBody pt={0} flex="1" overflowY="auto">
{eventComments.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiMessageSquare} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无评论记录
</Text>
<Text color={secondaryText} fontSize="xs" textAlign="center">
参与事件讨论分享您的观点
</Text>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={3}>
{eventComments.slice(0, 5).map((comment) => (
<Box
key={comment.id}
p={3}
borderRadius="md"
border="1px"
borderColor={borderColor}
_hover={{ bg: hoverBg }}
transition="all 0.2s"
>
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" noOfLines={3}>
{comment.content}
</Text>
<HStack justify="space-between" fontSize="xs" color={secondaryText} spacing={2}>
<HStack flexShrink={0}>
<Icon as={FiClock} />
<Text>{formatDate(comment.created_at)}</Text>
</HStack>
{comment.event_title && (
<Tooltip label={comment.event_title}>
<Badge
variant="subtle"
fontSize="xs"
maxW={{ base: '120px', md: '180px' }}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{comment.event_title}
</Badge>
</Tooltip>
)}
</HStack>
</VStack>
</Box>
))}
{eventComments.length > 5 && (
<Text fontSize="sm" color={secondaryText} textAlign="center">
{eventComments.length} 条评论
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
</VStack>
</Grid>
{/* 投资规划中心(整合了日历、计划、复盘) */}
<Box>
<InvestmentPlanningCenter />
</Box>
</Box>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
/**
* CalendarPanel - 投资日历面板组件
* 使用 FullCalendar 展示投资计划、复盘等事件
*/
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
import {
Box,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Spinner,
Center,
} from '@chakra-ui/react';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { DateClickArg } from '@fullcalendar/interaction';
import type { EventClickArg } from '@fullcalendar/core';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import { EventDetailModal } from './EventDetailModal';
import type { InvestmentEvent } from '@/types';
import './InvestmentCalendar.less';
// 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
dayjs.locale('zh-cn');
/**
* FullCalendar 事件类型
*/
interface CalendarEvent {
id: string;
title: string;
start: string;
date: string;
backgroundColor: string;
borderColor: string;
extendedProps: InvestmentEvent & {
isSystem: boolean;
};
}
/**
* CalendarPanel 组件
* 日历视图面板,显示所有投资事件
*/
export const CalendarPanel: React.FC = () => {
const {
allEvents,
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[]>([]);
// 转换数据为 FullCalendar 格式(使用 useMemo 缓存)
const calendarEvents: CalendarEvent[] = useMemo(() =>
allEvents.map(event => ({
...event,
id: `${event.source || 'user'}-${event.id}`,
title: event.title,
start: event.event_date,
date: event.event_date,
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
extendedProps: {
...event,
isSystem: event.source === 'future',
}
})), [allEvents]);
// 抽取公共的打开事件详情函数
const openEventDetail = useCallback((date: Date | null): void => {
if (!date) return;
const clickedDate = dayjs(date);
setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(event =>
dayjs(event.event_date).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
setIsDetailModalOpen(true);
}, [allEvents]);
// 处理日期点击
const handleDateClick = useCallback((info: DateClickArg): void => {
openEventDetail(info.date);
}, [openEventDetail]);
// 处理事件点击
const handleEventClick = useCallback((info: EventClickArg): void => {
openEventDetail(info.event.start);
}, [openEventDetail]);
return (
<Box>
<Box
height={{ base: '380px', md: '560px' }}
sx={{
// FullCalendar 按钮样式覆盖(与日历视图按钮颜色一致)
'.fc .fc-button': {
backgroundColor: '#805AD5 !important',
borderColor: '#805AD5 !important',
color: '#fff !important',
'&:hover': {
backgroundColor: '#6B46C1 !important',
borderColor: '#6B46C1 !important',
},
'&:disabled': {
backgroundColor: '#6B46C1 !important',
borderColor: '#6B46C1 !important',
opacity: '1 !important',
},
},
// 今天日期高亮边框
'.fc-daygrid-day.fc-day-today': {
border: '2px solid #805AD5 !important',
},
}}
>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: ''
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={1}
moreLinkText="+更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
titleFormat={{ year: 'numeric', month: 'long' }}
/>
</Box>
{/* 查看事件详情 Modal */}
<EventDetailModal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
selectedDate={selectedDate}
events={selectedDateEvents}
borderColor={borderColor}
secondaryText={secondaryText}
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

@@ -1,99 +0,0 @@
/**
* EventDetailModal - 事件详情弹窗组件
* 用于展示某一天的所有投资事件
* 使用 Ant Design 实现
*/
import React from 'react';
import { Modal, Space } from 'antd';
import type { Dayjs } from 'dayjs';
import { EventCard } from './EventCard';
import { EventEmptyState } from './EventEmptyState';
import type { InvestmentEvent } from '@/types';
/**
* EventDetailModal Props
*/
export interface EventDetailModalProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 选中的日期 */
selectedDate: Dayjs | null;
/** 选中日期的事件列表 */
events: InvestmentEvent[];
/** 边框颜色 */
borderColor?: string;
/** 次要文字颜色 */
secondaryText?: string;
/** 导航到计划列表 */
onNavigateToPlan?: () => void;
/** 导航到复盘列表 */
onNavigateToReview?: () => void;
/** 打开投资日历 */
onOpenInvestmentCalendar?: () => void;
}
/**
* EventDetailModal 组件
*/
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
isOpen,
onClose,
selectedDate,
events,
borderColor,
secondaryText,
onNavigateToPlan,
onNavigateToReview,
onOpenInvestmentCalendar,
}) => {
return (
<Modal
open={isOpen}
onCancel={onClose}
title={`${selectedDate?.format('YYYY年MM月DD日') || ''} 的事件`}
footer={null}
width={600}
maskClosable={false}
keyboard={true}
centered
styles={{
body: { paddingTop: 16, paddingBottom: 24 },
}}
>
{events.length === 0 ? (
<EventEmptyState
onNavigateToPlan={() => {
onClose();
onNavigateToPlan?.();
}}
onNavigateToReview={() => {
onClose();
onNavigateToReview?.();
}}
onOpenInvestmentCalendar={() => {
onClose();
onOpenInvestmentCalendar?.();
}}
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{events.map((event, idx) => (
<EventCard
key={idx}
event={event}
variant="detail"
borderColor={borderColor}
secondaryText={secondaryText}
/>
))}
</Space>
)}
</Modal>
);
};
export default EventDetailModal;

View File

@@ -1,198 +0,0 @@
/* EventFormModal.less - 投资计划/复盘弹窗响应式样式 */
// ==================== 变量定义 ====================
@mobile-breakpoint: 768px;
@modal-border-radius-mobile: 12px;
@modal-border-radius-desktop: 8px;
// 间距
@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-border: #f0f0f0;
@color-text-secondary: #999;
@color-error: #ff4d4f;
// ==================== 主样式 ====================
.event-form-modal {
// Modal 整体
.ant-modal-content {
border-radius: @modal-border-radius-desktop;
}
// Modal 标题放大加粗
.ant-modal-title {
font-size: 20px;
font-weight: 700;
}
.ant-modal-body {
padding: @spacing-xxl;
padding-top: 36px; // 增加标题与表单间距
}
.ant-form-item {
margin-bottom: @spacing-xl;
}
// 表单标签加粗,左对齐
.ant-form-item-label {
text-align: left !important;
> label {
font-weight: 600 !important;
color: #333;
}
}
// 字符计数样式
.ant-input-textarea-show-count::after {
font-size: @font-size-xs;
color: @color-text-secondary;
}
// 日期选择器全宽
.ant-picker {
width: 100%;
}
// 股票标签样式
.ant-tag {
margin: 2px;
border-radius: @spacing-xs;
}
// 模板按钮组
.template-buttons {
.ant-btn {
font-size: @font-size-xs;
}
}
// 底部操作栏布局
.modal-footer {
display: flex;
justify-content: flex-end;
}
// 加载状态
.ant-btn-loading {
opacity: 0.8;
}
// 错误状态动画
.ant-form-item-has-error {
.ant-input,
.ant-picker,
.ant-select-selector {
animation: shake 0.3s ease-in-out;
}
}
}
// ==================== 移动端适配 ====================
@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;
border-top: 1px solid @color-border;
}
// 表单项间距
.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;
}
// 底部按钮
.ant-modal-footer .ant-btn {
font-size: @font-size-md;
height: 40px;
border-radius: @spacing-sm;
}
}
}
// ==================== 动画 ====================
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}

View File

@@ -1,123 +0,0 @@
// src/views/Dashboard/components/InvestmentCalendar.less
// 颜色变量(与日历视图按钮一致的紫色)
@primary-color: #805AD5;
@primary-hover: #6B46C1;
@border-color: #e2e8f0;
@text-color: #2d3748;
@today-bg: #e6f3ff;
// 暗色模式颜色
@dark-border-color: #4a5568;
@dark-text-color: #e2e8f0;
@dark-today-bg: #2d3748;
// FullCalendar 自定义样式
.fc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
// 工具栏按钮紧密排列(提升优先级)
.fc .fc-toolbar.fc-header-toolbar {
justify-content: flex-start !important;
gap: 8px !important;
}
.fc .fc-toolbar-chunk:first-child {
display: flex !important;
gap: 4px !important;
}
.fc-theme-standard {
td, th {
border-color: @border-color;
}
}
// 按钮样式(针对 fc-button-group 内的按钮)
.fc .fc-toolbar .fc-button-group .fc-button {
background-color: @primary-color !important;
border-color: @primary-color !important;
color: #fff !important;
&:hover {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
&:not(:disabled):active,
&:not(:disabled).fc-button-active {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
}
// 今天按钮样式
.fc .fc-toolbar .fc-today-button {
background-color: @primary-color !important;
border-color: @primary-color !important;
color: #fff !important;
&:hover {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
}
// 选中状态disabled 表示当前视图包含今天)
&:disabled {
background-color: @primary-hover !important;
border-color: @primary-hover !important;
opacity: 1 !important;
color: #fff !important;
}
}
// 日期数字
.fc-daygrid-day-number {
color: @text-color;
font-weight: 500;
}
// 今天高亮
.fc-daygrid-day.fc-day-today {
background-color: @today-bg !important;
border: 2px solid @primary-color !important;
}
// 事件样式
.fc-event {
border: none;
padding: 2px 4px;
font-size: 12px;
font-weight: 500;
}
.fc-daygrid-event-dot {
display: none;
}
.fc-daygrid-day-events {
margin-top: 2px;
}
// 暗色模式支持
@media (prefers-color-scheme: dark) {
.fc-theme-standard {
td, th {
border-color: @dark-border-color;
}
}
.fc-daygrid-day-number {
color: @dark-text-color;
}
.fc-daygrid-day.fc-day-today {
background-color: @dark-today-bg !important;
}
.fc-col-header-cell-cushion,
.fc-daygrid-day-number {
color: @dark-text-color;
}
}

View File

@@ -1,587 +0,0 @@
// src/views/Dashboard/components/InvestmentCalendarChakra.js
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
VStack,
HStack,
Text,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
Badge,
IconButton,
Flex,
Grid,
useColorModeValue,
Divider,
Tooltip,
Icon,
Input,
FormControl,
FormLabel,
Textarea,
Select,
useToast,
Spinner,
Center,
Tag,
TagLabel,
TagLeftIcon,
} from '@chakra-ui/react';
import {
FiCalendar,
FiClock,
FiStar,
FiTrendingUp,
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiX,
} from 'react-icons/fi';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import TimelineChartModal from '../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import './InvestmentCalendar.less';
dayjs.locale('zh-cn');
export default function InvestmentCalendarChakra() {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const { isOpen: isTimelineModalOpen, onOpen: onTimelineModalOpen, onClose: onTimelineModalClose } = useDisclosure();
const { isOpen: isKLineModalOpen, onOpen: onKLineModalOpen, onClose: onKLineModalClose } = useDisclosure();
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const [events, setEvents] = useState([]);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [selectedStock, setSelectedStock] = useState(null);
const [loading, setLoading] = useState(false);
const [newEvent, setNewEvent] = useState({
title: '',
description: '',
type: 'plan',
importance: 3,
stocks: '',
});
// 加载事件数据
const loadEvents = useCallback(async () => {
try {
setLoading(true);
const base = getApiBase();
// 直接加载用户相关的事件(投资计划 + 关注的未来事件)
const userResponse = await fetch(base + '/api/account/calendar/events', {
credentials: 'include'
});
if (userResponse.ok) {
const userData = await userResponse.json();
if (userData.success) {
const allEvents = (userData.data || []).map(event => ({
...event,
id: `${event.source || 'user'}-${event.id}`,
title: event.title,
start: event.event_date,
date: event.event_date,
backgroundColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
borderColor: event.source === 'future' ? '#3182CE' : '#8B5CF6',
extendedProps: {
...event,
isSystem: event.source === 'future',
}
}));
setEvents(allEvents);
logger.debug('InvestmentCalendar', '日历事件加载成功', {
count: allEvents.length
});
}
}
} catch (error) {
logger.error('InvestmentCalendar', 'loadEvents', error);
// ❌ 移除数据加载失败 toast非关键操作
} finally {
setLoading(false);
}
}, []); // ✅ 移除 toast 依赖
useEffect(() => {
loadEvents();
}, [loadEvents]);
// 根据重要性获取颜色
const getEventColor = (importance) => {
if (importance >= 5) return '#E53E3E'; // 红色
if (importance >= 4) return '#ED8936'; // 橙色
if (importance >= 3) return '#ECC94B'; // 黄色
if (importance >= 2) return '#48BB78'; // 绿色
return '#3182CE'; // 蓝色
};
// 处理日期点击
const handleDateClick = (info) => {
const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate);
// 筛选当天的事件
const dayEvents = events.filter(event =>
dayjs(event.start).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
onOpen();
};
// 处理事件点击
const handleEventClick = (info) => {
const event = info.event;
const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate);
setSelectedDateEvents([{
title: event.title,
start: event.start,
extendedProps: {
...event.extendedProps,
},
}]);
onOpen();
};
// 添加新事件
const handleAddEvent = async () => {
try {
const base = getApiBase();
const eventData = {
...newEvent,
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
};
const response = await fetch(base + '/api/account/calendar/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(eventData),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
logger.info('InvestmentCalendar', '添加事件成功', {
eventTitle: eventData.title,
eventDate: eventData.event_date
});
toast({
title: '添加成功',
description: '投资计划已添加',
status: 'success',
duration: 3000,
});
onAddClose();
loadEvents();
setNewEvent({
title: '',
description: '',
type: 'plan',
importance: 3,
stocks: '',
});
}
}
} catch (error) {
logger.error('InvestmentCalendar', 'handleAddEvent', error, {
eventTitle: newEvent?.title
});
toast({
title: '添加失败',
description: '无法添加投资计划',
status: 'error',
duration: 3000,
});
}
};
// 删除用户事件
const handleDeleteEvent = async (eventId) => {
if (!eventId) {
logger.warn('InvestmentCalendar', '删除事件失败', '缺少事件 ID', { eventId });
toast({
title: '无法删除',
description: '缺少事件 ID',
status: 'error',
duration: 3000,
});
return;
}
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('InvestmentCalendar', '删除事件成功', { eventId });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadEvents();
}
} catch (error) {
logger.error('InvestmentCalendar', 'handleDeleteEvent', error, { eventId });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 处理股票点击 - 打开图表弹窗
const handleStockClick = (stockCodeOrName, eventDate) => {
// 解析股票代码(可能是 "600000" 或 "600000 平安银行" 格式)
let stockCode = stockCodeOrName;
let stockName = '';
if (typeof stockCodeOrName === 'string') {
const parts = stockCodeOrName.trim().split(/\s+/);
stockCode = parts[0];
stockName = parts.slice(1).join(' ');
}
// 添加交易所后缀(如果没有)
if (!stockCode.includes('.')) {
if (stockCode.startsWith('6')) {
stockCode = `${stockCode}.SH`;
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
stockCode = `${stockCode}.SZ`;
} else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) {
// 北交所股票
stockCode = `${stockCode}.BJ`;
}
}
setSelectedStock({
stock_code: stockCode,
stock_name: stockName || stockCode,
});
};
return (
<Card bg={bgColor} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiCalendar} color="orange.500" boxSize={5} />
<Heading size="md">投资日历</Heading>
</HStack>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
>
添加计划
</Button>
</Flex>
</CardHeader>
<CardBody pt={0}>
{loading ? (
<Center h="560px">
<Spinner size="xl" color="blue.500" />
</Center>
) : (
<Box height={{ base: '500px', md: '600px' }}>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: ''
}}
events={events}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
/>
</Box>
)}
</CardBody>
{/* 查看事件详情 Modal - 条件渲染 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedDateEvents.length === 0 ? (
<Center py={8}>
<VStack>
<Text color={secondaryText}>当天没有事件</Text>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => {
onClose();
onAddOpen();
}}
>
添加投资计划
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={4}>
{selectedDateEvents.map((event, idx) => (
<Box
key={idx}
p={4}
borderRadius="md"
border="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold" fontSize="lg">
{event.title}
</Text>
{event.extendedProps?.isSystem ? (
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
) : (
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
)}
</HStack>
<HStack spacing={2}>
<Icon as={FiStar} color="yellow.500" />
<Text fontSize="sm" color={secondaryText}>
重要度: {event.extendedProps?.importance || 3}/5
</Text>
</HStack>
</VStack>
{!event.extendedProps?.isSystem && (
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteEvent(event.extendedProps?.id)}
/>
)}
</Flex>
{event.extendedProps?.description && (
<Text fontSize="sm" color={secondaryText} mb={2}>
{event.extendedProps.description}
</Text>
)}
{event.extendedProps?.stocks && event.extendedProps.stocks.length > 0 && (
<VStack align="stretch" spacing={2}>
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
{event.extendedProps.stocks.map((stock, i) => (
<Tag
key={i}
size="sm"
colorScheme="blue"
cursor="pointer"
onClick={() => handleStockClick(stock, event.start)}
_hover={{ transform: 'scale(1.05)', shadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
{selectedStock && (
<HStack spacing={2}>
<Button
size="xs"
colorScheme="blue"
variant="outline"
leftIcon={<FiClock />}
onClick={onTimelineModalOpen}
>
分时图
</Button>
<Button
size="xs"
colorScheme="purple"
variant="outline"
leftIcon={<FiTrendingUp />}
onClick={onKLineModalOpen}
>
日K线
</Button>
</HStack>
)}
</VStack>
)}
</Box>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 添加投资计划 Modal - 条件渲染 */}
{isAddOpen && (
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
添加投资计划
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>标题</FormLabel>
<Input
value={newEvent.title}
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
placeholder="例如:关注半导体板块"
/>
</FormControl>
<FormControl>
<FormLabel>描述</FormLabel>
<Textarea
value={newEvent.description}
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel>类型</FormLabel>
<Select
value={newEvent.type}
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value })}
>
<option value="plan">投资计划</option>
<option value="reminder">提醒事项</option>
<option value="analysis">分析任务</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>重要度</FormLabel>
<Select
value={newEvent.importance}
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
>
<option value={5}> 非常重要</option>
<option value={4}> 重要</option>
<option value={3}> 一般</option>
<option value={2}> 次要</option>
<option value={1}> 不重要</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>相关股票用逗号分隔</FormLabel>
<Input
value={newEvent.stocks}
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
placeholder="例如600519,000858,002415"
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>
取消
</Button>
<Button
colorScheme="blue"
onClick={handleAddEvent}
isDisabled={!newEvent.title}
>
添加
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 分时图弹窗 */}
{selectedStock && (
<TimelineChartModal
isOpen={isTimelineModalOpen}
onClose={() => {
onTimelineModalClose();
setSelectedStock(null);
}}
stock={selectedStock}
eventTime={selectedDate?.toISOString()}
/>
)}
{/* K线图弹窗 */}
{selectedStock && (
<KLineChartModal
isOpen={isKLineModalOpen}
onClose={() => {
onKLineModalClose();
setSelectedStock(null);
}}
stock={selectedStock}
eventTime={selectedDate?.toISOString()}
/>
)}
</Card>
);
}

View File

@@ -1,251 +0,0 @@
/**
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
*
* 性能优化:
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
* - 使用 TypeScript 提供类型安全
*
* 组件架构:
* - InvestmentPlanningCenter (主组件)
* - CalendarPanel (日历面板,懒加载)
* - EventPanel (通用事件面板,用于计划和复盘)
* - PlanningContext (数据共享层)
*/
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
HStack,
Flex,
Icon,
useColorModeValue,
useToast,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Spinner,
Center,
Button,
ButtonGroup,
} from '@chakra-ui/react';
import {
FiCalendar,
FiTarget,
FiFileText,
FiList,
FiPlus,
} from 'react-icons/fi';
import { PlanningDataProvider } from './PlanningContext';
import type { InvestmentEvent, PlanningContextValue } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
import './InvestmentCalendar.less';
// 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() =>
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
);
const EventPanel = lazy(() =>
import('./EventPanel').then(module => ({ default: module.EventPanel }))
);
/**
* 面板加载占位符
*/
const PanelLoadingFallback: React.FC = () => (
<Center py={12}>
<Spinner size="xl" color="purple.500" thickness="4px" />
</Center>
);
/**
* InvestmentPlanningCenter 主组件
*/
const InvestmentPlanningCenter: React.FC = () => {
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
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');
// 全局数据状态
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
const [loading, setLoading] = useState<boolean>(false);
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);
/**
* 加载所有事件数据(日历事件 + 计划 + 复盘)
*/
const loadAllData = useCallback(async (): Promise<void> => {
try {
setLoading(true);
const base = getApiBase();
const response = await fetch(base + '/api/account/calendar/events', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setAllEvents(data.data || []);
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
count: data.data?.length || 0
});
}
}
} catch (error) {
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
} finally {
setLoading(false);
}
}, []);
// 组件挂载时加载数据
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 提供给子组件的 Context 值
const contextValue: PlanningContextValue = {
allEvents,
setAllEvents,
loadAllData,
loading,
setLoading,
openPlanModalTrigger,
openReviewModalTrigger,
toast,
borderColor,
textColor,
secondaryText,
cardBg,
setViewMode,
setListTab,
};
// 计算各类型事件数量
const planCount = allEvents.filter(e => e.type === 'plan').length;
const reviewCount = allEvents.filter(e => e.type === 'review').length;
return (
<PlanningDataProvider value={contextValue}>
<Card bg={bgColor} shadow="md">
<CardHeader pb={{ base: 2, md: 4 }} px={{ base: 3, md: 5 }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={2}>
<HStack spacing={{ base: 1, md: 2 }}>
<Icon as={FiTarget} color="purple.500" boxSize={{ base: 4, md: 5 }} />
<Heading size={{ base: 'sm', md: 'md' }}></Heading>
</HStack>
{/* 视图切换按钮组 - H5隐藏 */}
<ButtonGroup size="sm" isAttached variant="outline" display={{ base: 'none', md: 'flex' }}>
<Button
leftIcon={<Icon as={FiList} boxSize={4} />}
colorScheme={viewMode === 'list' ? 'purple' : 'gray'}
variant={viewMode === 'list' ? 'solid' : 'outline'}
onClick={() => setViewMode('list')}
>
</Button>
<Button
leftIcon={<Icon as={FiCalendar} boxSize={4} />}
colorScheme={viewMode === 'calendar' ? 'purple' : 'gray'}
variant={viewMode === 'calendar' ? 'solid' : 'outline'}
onClick={() => setViewMode('calendar')}
>
</Button>
</ButtonGroup>
</Flex>
</CardHeader>
<CardBody pt={0} px={{ base: 3, md: 5 }}>
{viewMode === 'calendar' ? (
/* 日历视图 */
<Suspense fallback={<PanelLoadingFallback />}>
<CalendarPanel />
</Suspense>
) : (
/* 列表视图:我的计划 / 我的复盘 切换 */
<Tabs
index={listTab}
onChange={setListTab}
variant="enclosed"
colorScheme="purple"
size={{ base: 'sm', md: 'md' }}
>
<Flex justify="space-between" align="center" mb={{ base: 2, md: 4 }} flexWrap="nowrap" gap={1}>
<TabList mb={0} borderBottom="none" flex="1" minW={0}>
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
<Icon as={FiTarget} mr={1} boxSize={{ base: 3, md: 4 }} />
({planCount})
</Tab>
<Tab fontSize={{ base: '11px', md: 'sm' }} px={{ base: 1, md: 4 }} whiteSpace="nowrap">
<Icon as={FiFileText} mr={1} boxSize={{ base: 3, md: 4 }} />
({reviewCount})
</Tab>
</TabList>
<Button
size="xs"
colorScheme="purple"
leftIcon={<Icon as={FiPlus} boxSize={3} />}
fontSize={{ base: '11px', md: 'sm' }}
flexShrink={0}
onClick={() => {
if (listTab === 0) {
setOpenPlanModalTrigger(prev => prev + 1);
} else {
setOpenReviewModalTrigger(prev => prev + 1);
}
}}
>
{listTab === 0 ? '新建计划' : '新建复盘'}
</Button>
</Flex>
<TabPanels>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<EventPanel
type="plan"
colorScheme="purple"
label="计划"
openModalTrigger={openPlanModalTrigger}
/>
</Suspense>
</TabPanel>
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<EventPanel
type="review"
colorScheme="green"
label="复盘"
openModalTrigger={openReviewModalTrigger}
/>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
)}
</CardBody>
</Card>
</PlanningDataProvider>
);
};
export default InvestmentPlanningCenter;

View File

@@ -1,588 +0,0 @@
// src/views/Dashboard/components/InvestmentPlansAndReviews.js
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
VStack,
HStack,
Text,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
Badge,
IconButton,
Flex,
useColorModeValue,
Divider,
Icon,
Input,
FormControl,
FormLabel,
Textarea,
Select,
useToast,
Spinner,
Center,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
Grid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react';
import {
FiCalendar,
FiClock,
FiEdit2,
FiTrash2,
FiSave,
FiPlus,
FiFileText,
FiTarget,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
dayjs.locale('zh-cn');
export default function InvestmentPlansAndReviews({ type = 'both' }) {
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
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');
const [plans, setPlans] = useState([]);
const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState('');
const [tagInput, setTagInput] = useState('');
// 加载数据
const loadData = useCallback(async () => {
try {
setLoading(true);
const base = getApiBase();
const response = await fetch(base + '/api/account/investment-plans', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const allItems = data.data || [];
setPlans(allItems.filter(item => item.type === 'plan'));
setReviews(allItems.filter(item => item.type === 'review'));
logger.debug('InvestmentPlansAndReviews', '数据加载成功', {
plansCount: allItems.filter(item => item.type === 'plan').length,
reviewsCount: allItems.filter(item => item.type === 'review').length
});
}
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'loadData', error);
// ❌ 移除数据加载失败 toast非关键操作
} finally {
setLoading(false);
}
}, []); // ✅ 移除 toast 依赖
useEffect(() => {
loadData();
}, [loadData]);
// 打开编辑/新建模态框
const handleOpenModal = (item = null, itemType = 'plan') => {
if (item) {
setEditingItem(item);
setFormData({
...item,
date: dayjs(item.date).format('YYYY-MM-DD'),
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: itemType,
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async () => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('InvestmentPlansAndReviews', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
type: formData.type
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id) => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('InvestmentPlansAndReviews', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadData();
}
} catch (error) {
logger.error('InvestmentPlansAndReviews', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = () => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态图标和颜色
const getStatusInfo = (status) => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green' };
case 'cancelled':
return { icon: FiXCircle, color: 'red' };
default:
return { icon: FiAlertCircle, color: 'blue' };
}
};
// 渲染单个卡片
const renderCard = (item) => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={item.type === 'plan' ? FiTarget : FiFileText} color="blue.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
leftIcon={<Icon as={statusInfo.icon} />}
>
{item.status === 'active' ? '进行中' :
item.status === 'completed' ? '已完成' : '已取消'}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
/>
</HStack>
</Flex>
{item.content && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<Tabs variant="enclosed" colorScheme="blue" defaultIndex={type === 'review' ? 1 : 0}>
<TabList>
<Tab>
<Icon as={FiTarget} mr={2} />
我的计划 ({plans.length})
</Tab>
<Tab>
<Icon as={FiFileText} mr={2} />
我的复盘 ({reviews.length})
</Tab>
</TabList>
<TabPanels>
{/* 计划面板 */}
<TabPanel px={0}>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'plan')}
>
新建计划
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="blue.500" />
</Center>
) : plans.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiTarget} boxSize={12} color="gray.300" />
<Text color={secondaryText}>暂无投资计划</Text>
<Button
size="sm"
colorScheme="blue"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'plan')}
>
创建第一个计划
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{plans.map(renderCard)}
</Grid>
)}
</VStack>
</TabPanel>
{/* 复盘面板 */}
<TabPanel px={0}>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'review')}
>
新建复盘
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="blue.500" />
</Center>
) : reviews.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiFileText} boxSize={12} color="gray.300" />
<Text color={secondaryText}>暂无复盘记录</Text>
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null, 'review')}
>
创建第一个复盘
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{reviews.map(renderCard)}
</Grid>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
{/* 编辑/新建模态框 - 条件渲染 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
{formData.type === 'plan' ? '投资计划' : '复盘记录'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>日期</FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel>标题</FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder={formData.type === 'plan' ? '例如:布局新能源板块' : '例如:本周交易复盘'}
/>
</FormControl>
<FormControl>
<FormLabel>内容</FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder={formData.type === 'plan' ?
'详细描述您的投资计划...' :
'记录您的交易心得和经验教训...'}
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel>相关股票</FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}>添加</Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel>标签</FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}>添加</Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{formData.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel>状态</FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
>
<option value="active">进行中</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
取消
</Button>
<Button
colorScheme="blue"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
}

View File

@@ -36,7 +36,6 @@ const HomePage: React.FC = () => {
// 响应式配置 // 响应式配置
const { const {
heroHeight,
headingSize, headingSize,
headingLetterSpacing, headingLetterSpacing,
heroTextSize, heroTextSize,
@@ -85,11 +84,11 @@ const HomePage: React.FC = () => {
const isMobile = isMobileDevice(); const isMobile = isMobileDevice();
return ( return (
<Box> <Box minH="100%">
{/* Hero Section - 深色科技风格 */} {/* Hero Section - 深色科技风格,自适应容器高度 */}
<Box <Box
position="relative" position="relative"
minH={heroHeight} minH="100%"
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)" bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
overflow="hidden" overflow="hidden"
> >
@@ -98,7 +97,7 @@ const HomePage: React.FC = () => {
<VStack <VStack
spacing={{ base: 5, md: 8, lg: 10 }} spacing={{ base: 5, md: 8, lg: 10 }}
align="stretch" align="stretch"
minH={heroHeight} py={{ base: 8, md: 10, lg: 12 }}
justify="center" justify="center"
> >
{/* 主标题区域 */} {/* 主标题区域 */}

View File

@@ -1,767 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Badge,
Box,
Button,
Flex,
Grid,
Icon,
Progress,
Spacer,
Stack,
Stat,
StatHelpText,
StatLabel,
StatNumber,
Switch,
Text,
useColorMode,
useColorModeValue,
} from '@chakra-ui/react';
// Assets
import BackgroundCard1 from 'assets/img/BackgroundCard1.png';
import BgMusicCard from 'assets/img/BgMusicCard.png';
import BgMusicCardDark from 'assets/img/bgMusicCardDark.png';
import {
ClockIcon,
DocumentIcon,
RocketIcon,
SettingsIcon,
WalletIcon,
} from 'components/Icons/Icons';
import { AiFillBackward, AiFillForward } from 'react-icons/ai';
import { BsBatteryCharging, BsMusicNoteBeamed } from 'react-icons/bs';
// Custom components
import EventCalendar from 'components/Calendars/EventCalendar';
import Card from 'components/Card/Card';
import CardHeader from 'components/Card/CardHeader';
import LineChart from 'components/Charts/LineChart';
import IconBox from 'components/Icons/IconBox';
import { HSeparator } from 'components/Separator/Separator';
import TimelineRow from 'components/Tables/TimelineRow';
import React, { useState } from 'react';
import {
FaCheckCircle,
FaLightbulb,
FaPaypal,
FaPlay,
FaRegLightbulb,
FaShare,
FaUser,
FaWallet,
} from 'react-icons/fa';
import { RiArrowDropRightLine, RiMastercardFill } from 'react-icons/ri';
import { calendarDataWidgets } from 'variables/calendar';
import {
lineChartDataWidgets1,
lineChartDataWidgets2,
lineChartDataWidgets3,
lineChartOptionsWidgets1,
lineChartOptionsWidgets2,
lineChartOptionsWidgets3,
} from 'variables/charts';
import { timelineData } from 'variables/general';
function Widgets() {
const [toggleSwitch, setToggleSwitch] = useState(false);
const { colorMode } = useColorMode();
const textColor = useColorModeValue('gray.700', 'white');
const iconBlue = useColorModeValue('blue.500', 'white');
const secondaryIconBlue = useColorModeValue('gray.100', 'blue.500');
const iconBoxInside = useColorModeValue('white', 'blue.500');
const bgCard = useColorModeValue(
'linear-gradient(81.62deg, #313860 2.25%, #151928 79.87%)',
'navy.800'
);
const iconBoxColor = useColorModeValue(
'linear-gradient(81.62deg, #313860 2.25%, #151928 79.87%)',
'blue.500'
);
return (
<Flex direction='column' pt={{ sm: '125px', lg: '75px' }}>
<Grid
templateColumns={{ sm: '1fr', md: '1fr 1fr', lg: '1fr 1fr 2fr' }}
templateRows='1fr'
gap='24px'
mb='24px'
>
<Stack direction='column' spacing='24px'>
<Card bg={bgCard}>
<Flex justify='space-between' w='100%' align='center'>
<Flex direction='column'>
<Text fontSize='sm' color='#fff' fontWeight='normal' mb='2px'>
Battery Health
</Text>
<Text fontSize='lg' color='#fff' fontWeight='bold'>
99%
</Text>
</Flex>
<IconBox h={'45px'} w={'45px'} bg={iconBoxInside}>
<Icon
as={BsBatteryCharging}
h={'24px'}
w={'24px'}
color={iconBlue}
/>
</IconBox>
</Flex>
</Card>
<Card bg={bgCard}>
<Flex justify='space-between' w='100%' align='center'>
<Flex direction='column'>
<Text fontSize='sm' color='#fff' fontWeight='normal' mb='2px'>
Music Volume
</Text>
<Text fontSize='lg' color='#fff' fontWeight='bold'>
15/100
</Text>
</Flex>
<IconBox h={'45px'} w={'45px'} bg={iconBoxInside}>
<Icon
as={BsMusicNoteBeamed}
h={'24px'}
w={'24px'}
color={iconBlue}
/>
</IconBox>
</Flex>
</Card>
</Stack>
<Card px='0px' maxH='230px' pb='0px'>
<CardHeader px='22px'>
<Stat me='auto'>
<StatLabel fontSize='xs' color='gray.400' fontWeight='normal'>
Income
</StatLabel>
<Flex>
<StatNumber fontSize='lg' color={textColor}>
$130,912
</StatNumber>
<StatHelpText
alignSelf='flex-end'
justifySelf='flex-end'
m='0px'
ps='4px'
color='green.400'
fontWeight='bold'
fontSize='sm'
>
+90%
</StatHelpText>
</Flex>
</Stat>
</CardHeader>
<Box w='100%'>
<LineChart
chartData={lineChartDataWidgets1}
chartOptions={lineChartOptionsWidgets1}
/>
</Box>
</Card>
<Card
px='0px'
maxH='230px'
pb='0px'
gridColumn={{ md: '1 / 3', lg: 'auto' }}
>
<CardHeader px='22px'>
<Flex justify='space-between' w='100%'>
<Flex align='center'>
<IconBox h={'45px'} w={'45px'} bg='blue.500' me='16px'>
<Icon
as={FaCheckCircle}
h={'24px'}
w={'24px'}
color='white'
/>
</IconBox>
<Flex direction='column'>
<Text color='gray.400' fontSize='xs' fontWeight='normal'>
Tasks
</Text>
<Text color={textColor} fontSize='lg' fontWeight='bold'>
480
</Text>
</Flex>
</Flex>
<Flex direction='column' minW='125px' alignSelf='flex-end'>
<Text color='gray.400' fontWeight='normal' fontSize='xs'>
60%
</Text>
<Progress
colorScheme='blue'
borderRadius='15px'
h='6px'
value={60}
/>
</Flex>
</Flex>
</CardHeader>
<Box w='100%'>
<LineChart
chartData={lineChartDataWidgets2}
chartOptions={lineChartOptionsWidgets2}
/>
</Box>
</Card>
</Grid>
<Grid
templateColumns={{
sm: '1fr',
md: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
}}
gap='24px'
mb='24px'
>
<Card>
<CardHeader mb='16px'>
<Flex direction='column'>
<Text color={textColor} fontSize='lg' fontWeight='bold' mb='4px'>
Upcoming events
</Text>
<Text color='gray.400' fontSize='sm' fontWeight='bold'>
Joined
</Text>
</Flex>
</CardHeader>
<Flex direction='column'>
<Flex align='center' mb='22px'>
<IconBox h={'45px'} w={'45px'} bg={secondaryIconBlue} me='16px'>
<Icon as={WalletIcon} h={'24px'} w={'24px'} color={iconBlue} />
</IconBox>
<Flex direction='column'>
<Text color={textColor} fontSize='sm' fontWeight='bold'>
Cyber Week
</Text>
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
27 March 2020, at 12:30 PM
</Text>
</Flex>
</Flex>
<Flex align='center'>
<IconBox h={'45px'} w={'45px'} bg={secondaryIconBlue} me='16px'>
<Icon as={ClockIcon} h={'24px'} w={'24px'} color={iconBlue} />
</IconBox>
<Flex direction='column'>
<Text color={textColor} fontSize='sm' fontWeight='bold'>
Meeting with Marry
</Text>
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
24 March 2020, at 10:00 PM
</Text>
</Flex>
</Flex>
</Flex>
</Card>
<Stack direction={{ sm: 'column', md: 'row' }} spacing='24px'>
<Card p='16px' display='flex' align='center' justify='center'>
<Flex direction='column' align='center' w='100%' py='14px'>
<IconBox h={'60px'} w={'60px'} bg='blue.500'>
<Icon h={'24px'} w={'24px'} color='white' as={FaWallet} />
</IconBox>
<Flex
direction='column'
m='14px'
justify='center'
textAlign='center'
align='center'
w='100%'
>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
Salary
</Text>
<Text
mb='24px'
fontSize='xs'
color='gray.400'
fontWeight='semibold'
>
Belong Interactive
</Text>
<HSeparator />
</Flex>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
+$2000
</Text>
</Flex>
</Card>
<Card p='16px' display='flex' align='center' justify='center'>
<Flex
direction='column'
align='center'
justify='center'
w='100%'
py='14px'
>
<IconBox h={'60px'} w={'60px'} bg='blue.500'>
<Icon h={'24px'} w={'24px'} color='white' as={FaPaypal} />
</IconBox>
<Flex
direction='column'
m='14px'
justify='center'
textAlign='center'
align='center'
w='100%'
>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
Paypal
</Text>
<Text
mb='24px'
fontSize='xs'
color='gray.400'
fontWeight='semibold'
>
Freelance Payment
</Text>
<HSeparator />
</Flex>
<Text fontSize='lg' color={textColor} fontWeight='bold'>
$455.00
</Text>
</Flex>
</Card>
</Stack>
<Card
backgroundImage={
colorMode === 'light'
? BackgroundCard1
: 'linear-gradient(180deg, #3182CE 0%, #63B3ED 100%)'
}
backgroundRepeat='no-repeat'
background='cover'
bgPosition='10%'
p='16px'
h={{ sm: '220px', xl: '100%' }}
gridColumn={{ md: '1 / 3', lg: 'auto' }}
>
<Flex
direction='column'
color='white'
h='100%'
p='0px 10px 20px 10px'
w='100%'
>
<Flex justify='space-between' align='center'>
<Text fontWeight='bold'>Argon x Chakra UI</Text>
<Icon as={RiMastercardFill} w='48px' h='auto' color='gray.400' />
</Flex>
<Spacer />
<Flex direction='column'>
<Box>
<Text fontSize='xl' letterSpacing='2px' fontWeight='bold'>
7812 2139 0823 XXXX
</Text>
</Box>
<Flex mt='14px'>
<Flex direction='column' me='34px'>
<Text fontSize='xs'>VALID THRU</Text>
<Text fontSize='xs' fontWeight='bold'>
05/24
</Text>
</Flex>
<Flex direction='column'>
<Text fontSize='xs'>CVV</Text>
<Text fontSize='xs' fontWeight='bold'>
09X
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
</Card>
</Grid>
<Grid
templateColumns={{
sm: '1fr',
md: '1fr 1fr',
lg: '1.5fr 1fr 1.2fr 1fr 1fr',
}}
gap='24px'
mb='24px'
>
<Card>
<CardHeader mb='24px'>
<Flex justify='space-between' w='100%' align='center'>
<Text color={textColor} fontWeight='bold' fontSize='lg'>
Full Body
</Text>
<Badge
bg={colorMode === 'light' ? 'red.100' : 'red.500'}
color={colorMode === 'light' ? 'red.500' : 'white'}
w='85px'
py='6px'
borderRadius='12px'
textAlign='center'
>
MODERATE
</Badge>
</Flex>
</CardHeader>
<Text color='gray.400' fontWeight='normal' fontSize='sm'>
What matters is the people who are sparked by it. And the people who
are liked.
</Text>
</Card>
<Card>
<CardHeader mb='22px'>
<Flex justify='space-between' align='center' w='100%'>
<Text fontSize='xs' color='gray.400' fontWeight='bold'>
{toggleSwitch ? 'ON' : 'OFF'}
</Text>
<Switch
colorScheme='blue'
onChange={() => setToggleSwitch(!toggleSwitch)}
/>
</Flex>
</CardHeader>
<Flex direction='column'>
<Icon
as={toggleSwitch ? FaLightbulb : FaRegLightbulb}
w='52px'
h='52px'
color='gray.400'
mb='16px'
/>
<Text color={textColor} fontWeight='bold'>
Lights
</Text>
</Flex>
</Card>
<Card px='0px' pb='0px' gridColumn={{ md: '1 / 3', lg: 'auto' }}>
<CardHeader px='22px'>
<Stat me='auto'>
<StatLabel fontSize='xs' color='gray.400' fontWeight='normal'>
Calories
</StatLabel>
<Flex>
<StatNumber fontSize='lg' color={textColor}>
187
</StatNumber>
<StatHelpText
alignSelf='flex-end'
justifySelf='flex-end'
m='0px'
ps='4px'
color='green.400'
fontWeight='bold'
fontSize='sm'
>
+5%
</StatHelpText>
</Flex>
</Stat>
</CardHeader>
<Box w='100%' maxH='100px'>
<LineChart
chartData={lineChartDataWidgets3}
chartOptions={lineChartOptionsWidgets3}
/>
</Box>
</Card>
<Card>
<Flex direction='column'>
<IconBox h={'45px'} w={'45px'} bg='blue.500' mb='24px'>
<Icon as={FaShare} h={'24px'} w={'24px'} color='white' />
</IconBox>
<Flex direction='column'>
<Text color={textColor} fontSize='2xl' fontWeight='bold'>
754
<Text as='span' color='gray.400' fontSize='sm' ms='2px'>
m
</Text>
</Text>
<Text fontSize='sm' color='gray.400' fontWeight='normal'>
New York City
</Text>
</Flex>
</Flex>
</Card>
<Card>
<CardHeader mb='42px'>
<Text color='gray.400' fontSize='xs' fontWeight='normal'>
STEPS
</Text>
</CardHeader>
<Stat>
<StatNumber
color={textColor}
fontWeight='bold'
fontSize='2xl'
mb='6px'
>
11.4K
</StatNumber>
<StatHelpText
bg='green.100'
color='green'
w='fit-content'
borderRadius='12px'
fontSize='10px'
p='6px 12px'
>
+4.3%
</StatHelpText>
</Stat>
</Card>
</Grid>
<Grid templateColumns={{ sm: '1fr', lg: '1fr .5fr .7fr' }} gap='24px'>
<Card minH='550px'>
<CardHeader mb='6px'>
<Flex direction='column'>
<Text color={textColor} fontSize='lg' fontWeight='bold' mb='6px'>
Calendar
</Text>
<Text color='gray.400' fontSize='sm' fontWeight='normal'>
Wednesday, 2022
</Text>
</Flex>
</CardHeader>
<Box position='relative' display='block' height='100%'>
<EventCalendar
initialDate='2022-10-01'
calendarData={calendarDataWidgets}
/>
</Box>
</Card>
<Stack direction='column' spacing='24px'>
<Card>
<Text fontSize='lg' text={textColor} fontWeight='bold'>
Categories
</Text>
<Stack direction='column' spacing='24px' w='100%' pt='28px'>
<Flex align='center' w='100%'>
<Flex align='center'>
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
<RocketIcon h={'20px'} w={'20px'} color='white' />
</IconBox>
<Flex direction='column'>
<Text fontSize='sm' fontWeight='bold' color={textColor}>
Devices
</Text>
<Text color='gray.400' fontSize='xs'>
250 in stock,{' '}
<Text as='span' fontWeight='bold'>
346+ sold
</Text>
</Text>
</Flex>
</Flex>
<Spacer />
<Button variant='no-effects' px='0px'>
<Icon
as={RiArrowDropRightLine}
color='gray.400'
w='30px'
h='30px'
cursor='pointer'
transition='all .25s ease'
_hover={{ transform: 'translateX(25%)' }}
/>
</Button>
</Flex>
<Flex align='center' w='100%'>
<Flex align='center'>
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
<SettingsIcon h={'20px'} w={'20px'} color='white' />
</IconBox>
<Flex direction='column'>
<Text fontSize='sm' fontWeight='bold' color={textColor}>
Tickets
</Text>
<Text color='gray.400' fontSize='xs'>
123 closed,{' '}
<Text as='span' fontWeight='bold'>
15 open
</Text>
</Text>
</Flex>
</Flex>
<Spacer />
<Button variant='no-effects' px='0px'>
<Icon
as={RiArrowDropRightLine}
color='gray.400'
w='30px'
h='30px'
cursor='pointer'
transition='all .25s ease'
_hover={{ transform: 'translateX(25%)' }}
/>
</Button>
</Flex>
<Flex align='center' w='100%'>
<Flex align='center'>
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
<DocumentIcon h={'20px'} w={'20px'} color='white' />
</IconBox>
<Flex direction='column'>
<Text fontSize='sm' fontWeight='bold' color={textColor}>
Error logs
</Text>
<Text color='gray.400' fontSize='xs'>
1 is active,{' '}
<Text as='span' fontWeight='bold'>
40 closed
</Text>
</Text>
</Flex>
</Flex>
<Spacer />
<Button variant='no-effects' px='0px'>
<Icon
as={RiArrowDropRightLine}
color='gray.400'
w='30px'
h='30px'
cursor='pointer'
transition='all .25s ease'
_hover={{ transform: 'translateX(25%)' }}
/>
</Button>
</Flex>
<Flex align='center' w='100%'>
<Flex align='center'>
<IconBox h={'40px'} w={'40px'} bg={iconBoxColor} me='18px'>
<Icon as={FaUser} h={'20px'} w={'20px'} color='white' />
</IconBox>
<Flex direction='column'>
<Text fontSize='sm' fontWeight='bold' color={textColor}>
Happy Users
</Text>
<Text color='gray.400' fontSize='xs'>
<Text as='span' fontWeight='bold'>
+430
</Text>
</Text>
</Flex>
</Flex>
<Spacer />
<Button variant='no-effects' px='0px'>
<Icon
as={RiArrowDropRightLine}
color='gray.400'
w='30px'
h='30px'
cursor='pointer'
transition='all .25s ease'
_hover={{ transform: 'translateX(25%)' }}
/>
</Button>
</Flex>
</Stack>
</Card>
<Card
bgImage={colorMode === 'light' ? BgMusicCard : BgMusicCardDark}
bgRepeat='no-repeat'
>
<Flex direction='column' w='100%' mb='60px'>
<Text color='#fff' fontWeight='bold' fontSize='lg'>
Some Kind of Blues
</Text>
<Text color='#fff' fontWeight='normal' fontSize='sm'>
Deftones
</Text>
</Flex>
<Stack direction='row' spacing='18px'>
<Button
variant='outline'
colorScheme='whiteAlpha'
borderRadius='50px'
w='45px'
h='45px'
>
<Icon as={AiFillBackward} color='#fff' w='26px' h='26px' />
</Button>
<Button
variant='outline'
colorScheme='whiteAlpha'
borderRadius='50px'
w='45px'
h='45px'
>
<Icon as={FaPlay} color='#fff' w='18px' h='18px' />
</Button>
<Button
variant='outline'
colorScheme='whiteAlpha'
borderRadius='50px'
w='45px'
h='45px'
>
<Icon as={AiFillForward} color='#fff' w='26px' h='26px' />
</Button>
</Stack>
</Card>
</Stack>
<Card pb='0px'>
<CardHeader mb='34px'>
<Flex direction='column'>
<Text
fontSize='lg'
color={textColor}
fontWeight='bold'
pb='.5rem'
>
Orders overview
</Text>
<Text fontSize='sm' color='gray.400' fontWeight='normal'>
<Text fontWeight='bold' as='span' color='green.500'>
+30%
</Text>{' '}
this month.
</Text>
</Flex>
</CardHeader>
<Flex direction='column' ms='8px' position='relative'>
{timelineData.map((row, index, arr) => {
return (
<TimelineRow
logo={row.logo}
title={row.title}
date={row.date}
color={row.color}
index={index}
arrLength={arr.length}
key={index}
/>
);
})}
</Flex>
</Card>
</Grid>
</Flex>
);
}
export default Widgets;

View File

@@ -0,0 +1,123 @@
// 社区动态卡片
import React, { useState } from 'react';
import { Box, Text, VStack, HStack, Icon, Button } from '@chakra-ui/react';
import { Newspaper, Flame, MessageCircle } from 'lucide-react';
const CommunityFeedCard = ({
myPosts = [
{ id: 1, title: '关于新能源车下半场的思考', date: '2025/12/18', replies: 32, isHot: true },
{ id: 2, title: '半导体行业深度分析', date: '2025/12/15', replies: 18, isHot: false },
],
participatedPosts = [
{ id: 3, title: 'AI产业链投资机会分析', date: '2025/12/17', replies: 45, isHot: true },
{ id: 4, title: '消费板块复苏节奏讨论', date: '2025/12/14', replies: 12, isHot: false },
],
onPostClick,
}) => {
const [activeTab, setActiveTab] = useState('my'); // 'my' | 'participated'
const currentPosts = activeTab === 'my' ? myPosts : participatedPosts;
return (
<Box
bg="rgba(26, 26, 46, 0.7)"
borderRadius="lg"
overflow="hidden"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
backdropFilter="blur(8px)"
>
{/* 标题栏 */}
<HStack
px={4}
py={2}
bg="rgba(15, 15, 26, 0.8)"
borderBottom="1px solid"
borderColor="rgba(212, 175, 55, 0.1)"
>
<Icon as={Newspaper} boxSize={4} color="#3B82F6" />
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
社区动态
</Text>
</HStack>
{/* 内容区 */}
<Box p={3}>
<VStack spacing={2} align="stretch">
{/* Tab 切换 - 更紧凑 */}
<HStack spacing={3} mb={1}>
<Button
variant="ghost"
size="xs"
color={activeTab === 'my' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
fontWeight={activeTab === 'my' ? 'bold' : 'normal'}
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
onClick={() => setActiveTab('my')}
px={2}
h="24px"
>
[我发布的]
</Button>
<Button
variant="ghost"
size="xs"
color={activeTab === 'participated' ? 'rgba(212, 175, 55, 0.9)' : 'rgba(255, 255, 255, 0.5)'}
fontWeight={activeTab === 'participated' ? 'bold' : 'normal'}
_hover={{ color: 'rgba(212, 175, 55, 0.9)' }}
onClick={() => setActiveTab('participated')}
px={2}
h="24px"
>
[我参与的]
</Button>
</HStack>
{/* 帖子列表 */}
<VStack spacing={2} align="stretch">
{currentPosts.map((post) => (
<Box
key={post.id}
py={2.5}
px={2}
cursor="pointer"
transition="all 0.2s"
borderRadius="md"
_hover={{
bg: 'rgba(212, 175, 55, 0.08)',
pl: 3,
}}
onClick={() => onPostClick?.(post)}
>
<Text
fontSize="sm"
fontWeight="medium"
color="rgba(255, 255, 255, 0.9)"
noOfLines={1}
mb={1}
>
{post.title}
</Text>
<HStack spacing={3} fontSize="xs" color="rgba(255, 255, 255, 0.5)">
<Text>{post.date}</Text>
<Text>·</Text>
<HStack spacing={1}>
{post.isHot ? (
<Icon as={Flame} boxSize={3} color="#F97316" />
) : (
<Icon as={MessageCircle} boxSize={3} />
)}
<Text color={post.isHot ? '#F97316' : 'inherit'}>
{post.replies}回复
</Text>
</HStack>
</HStack>
</Box>
))}
</VStack>
</VStack>
</Box>
</Box>
);
};
export default CommunityFeedCard;

View File

@@ -0,0 +1,158 @@
// 我的预测卡片
import React from 'react';
import { Box, Text, VStack, HStack, Button, Icon } from '@chakra-ui/react';
import { Zap, History, TrendingUp, TrendingDown } from 'lucide-react';
const PredictionCard = ({
question = '大A 2025年收盘价?',
myBet = { type: '看涨', points: 500 },
winRate = 58,
odds = 1.8,
onBullish,
onBearish,
onViewHistory,
}) => {
return (
<Box
bg="rgba(26, 26, 46, 0.7)"
borderRadius="lg"
overflow="hidden"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
backdropFilter="blur(8px)"
>
{/* 标题栏 */}
<HStack
px={4}
py={2}
bg="rgba(15, 15, 26, 0.8)"
borderBottom="1px solid"
borderColor="rgba(212, 175, 55, 0.1)"
>
<Icon as={Zap} boxSize={4} color="#FBBF24" />
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.95)">
我的预测
</Text>
</HStack>
{/* 内容区 - 更紧凑 */}
<Box p={3}>
<VStack spacing={3} align="stretch">
{/* 预测问题 - 带渐变背景 */}
<Box
bg="linear-gradient(135deg, rgba(30, 30, 50, 0.9) 0%, rgba(20, 20, 35, 0.95) 100%)"
borderRadius="md"
p={3}
textAlign="center"
position="relative"
overflow="hidden"
>
{/* 装饰性弧线 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="100px"
h="50px"
borderRadius="50%"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
borderBottomColor="transparent"
borderLeftColor="transparent"
/>
<Text
fontSize="md"
fontWeight="bold"
color="rgba(255, 255, 255, 0.95)"
position="relative"
zIndex={1}
>
{question}
</Text>
{/* 看涨/看跌按钮 - 更紧凑 */}
<HStack spacing={2} mt={3} justify="center">
<Button
flex={1}
maxW="110px"
h="34px"
bg="linear-gradient(135deg, #DC2626 0%, #EF4444 100%)"
color="white"
fontWeight="bold"
fontSize="sm"
borderRadius="full"
_hover={{
bg: 'linear-gradient(135deg, #B91C1C 0%, #DC2626 100%)',
transform: 'scale(1.02)',
}}
_active={{ transform: 'scale(0.98)' }}
leftIcon={<Icon as={TrendingUp} boxSize={3.5} />}
onClick={onBullish}
>
看涨
</Button>
<Button
flex={1}
maxW="110px"
h="34px"
bg="linear-gradient(135deg, #16A34A 0%, #22C55E 100%)"
color="white"
fontWeight="bold"
fontSize="sm"
borderRadius="full"
_hover={{
bg: 'linear-gradient(135deg, #15803D 0%, #16A34A 100%)',
transform: 'scale(1.02)',
}}
_active={{ transform: 'scale(0.98)' }}
leftIcon={<Icon as={TrendingDown} boxSize={3.5} />}
onClick={onBearish}
>
看跌
</Button>
</HStack>
</Box>
{/* 底部信息 - 合并为两行紧凑布局 */}
<HStack justify="space-between" fontSize="xs" px={1}>
<HStack spacing={1}>
<Text color="rgba(255, 255, 255, 0.5)">我的下注:</Text>
<Text color="#EF4444" fontWeight="medium">{myBet.type}</Text>
<Text color="rgba(212, 175, 55, 0.9)" fontWeight="medium">{myBet.points}积分</Text>
</HStack>
<Button
size="xs"
variant="ghost"
color="rgba(255, 255, 255, 0.6)"
leftIcon={<Icon as={History} boxSize={3} />}
_hover={{
color: 'rgba(212, 175, 55, 0.9)',
bg: 'rgba(212, 175, 55, 0.1)',
}}
onClick={onViewHistory}
px={2}
h="22px"
>
历史战绩
</Button>
</HStack>
<HStack fontSize="xs" px={1} spacing={4}>
<HStack spacing={1}>
<Text color="rgba(255, 255, 255, 0.5)">当前胜率:</Text>
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">{winRate}%</Text>
</HStack>
<HStack spacing={1}>
<Text color="rgba(255, 255, 255, 0.5)">赔率:</Text>
<Text color="rgba(255, 255, 255, 0.9)" fontWeight="medium">{odds}</Text>
</HStack>
</HStack>
</VStack>
</Box>
</Box>
);
};
export default PredictionCard;

View File

@@ -0,0 +1,3 @@
// 价值论坛子组件导出
export { default as PredictionCard } from './PredictionCard';
export { default as CommunityFeedCard } from './CommunityFeedCard';

View File

@@ -0,0 +1,56 @@
// 价值论坛 / 互动中心组件 (Forum Center)
import React from 'react';
import { Box, Text, HStack, SimpleGrid, Icon } from '@chakra-ui/react';
import { MessageCircle } from 'lucide-react';
import GlassCard from '@components/GlassCard';
import { PredictionCard, CommunityFeedCard } from './components';
const ForumCenter = () => {
return (
<GlassCard
variant="transparent"
rounded="2xl"
padding="md"
hoverable={false}
cornerDecor
>
{/* 标题栏 */}
<HStack mb={4} spacing={2}>
<Icon
as={MessageCircle}
boxSize={5}
color="rgba(212, 175, 55, 0.9)"
/>
<Text
fontSize="lg"
fontWeight="bold"
color="rgba(255, 255, 255, 0.95)"
letterSpacing="wide"
>
价值论坛 / 互动中心
</Text>
<Box
h="1px"
flex={1}
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
ml={2}
/>
</HStack>
{/* 两列布局:预测卡片(2) + 社区动态(3) */}
<SimpleGrid
columns={{ base: 1, md: 5 }}
spacing={4}
sx={{
'& > *:first-of-type': { gridColumn: { md: 'span 2' } },
'& > *:last-of-type': { gridColumn: { md: 'span 3' } },
}}
>
<PredictionCard />
<CommunityFeedCard />
</SimpleGrid>
</GlassCard>
);
};
export default ForumCenter;

View File

@@ -0,0 +1,49 @@
// 市场概览仪表盘主组件 - 投资仪表盘
import React from 'react';
import { Box, Text, HStack, Icon } from '@chakra-ui/react';
import { TrendingUp } from 'lucide-react';
import GlassCard from '@components/GlassCard';
import { MarketOverview } from './components';
import { MOCK_MARKET_STATS } from './constants';
const MarketDashboard = ({
marketStats = MOCK_MARKET_STATS,
}) => {
return (
<GlassCard
variant="transparent"
rounded="2xl"
padding="md"
hoverable={false}
cornerDecor
>
{/* 标题栏 */}
<HStack mb={4} spacing={2}>
<Icon
as={TrendingUp}
boxSize={5}
color="rgba(212, 175, 55, 0.9)"
/>
<Text
fontSize="lg"
fontWeight="bold"
color="rgba(255, 255, 255, 0.95)"
letterSpacing="wide"
>
投资仪表盘
</Text>
<Box
h="1px"
flex={1}
bgGradient="linear(to-r, rgba(212, 175, 55, 0.4), transparent)"
ml={2}
/>
</HStack>
{/* 市场概况:上证 + 深证 + 创业板指+涨跌 + 热门板块 */}
<MarketOverview marketStats={marketStats} />
</GlassCard>
);
};
export default MarketDashboard;

View File

@@ -0,0 +1,45 @@
// 热点概念组件
import React from 'react';
import { Box, Text, VStack, SimpleGrid, HStack, Icon } from '@chakra-ui/react';
import { Flame } from 'lucide-react';
import { ConceptItem } from './atoms';
import { THEME } from '../constants';
const HotConcepts = ({ concepts = [], onConceptClick }) => {
return (
<Box
borderRadius="xl"
p={4}
h="100%"
>
<VStack align="stretch" spacing={3}>
{/* 标题 */}
<HStack spacing={2}>
<Icon as={Flame} boxSize={4} color={THEME.status.up} />
<Text
fontSize="sm"
fontWeight="bold"
color="rgba(255, 255, 255, 0.9)"
>
热点概念
</Text>
</HStack>
{/* 概念列表 */}
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={2}>
{concepts.map((concept) => (
<ConceptItem
key={concept.id}
name={concept.name}
change={concept.change}
trend={concept.trend}
onClick={() => onConceptClick?.(concept)}
/>
))}
</SimpleGrid>
</VStack>
</Box>
);
};
export default HotConcepts;

View File

@@ -0,0 +1,45 @@
// 市场概况组件 - 三列等宽布局
import React from 'react';
import { Box, Grid } from '@chakra-ui/react';
import {
IndexKLineCard,
GemIndexCard,
} from './atoms';
const MarketOverview = ({ marketStats = {} }) => {
return (
<Box borderRadius="xl">
{/* 三列等宽网格布局 */}
<Grid
templateColumns={{ base: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }}
gap={3}
>
{/* 上证指数 - K线卡片 */}
<IndexKLineCard
indexCode="sh000001"
name="上证指数"
height="220px"
/>
{/* 深证成指 - K线卡片 */}
<IndexKLineCard
indexCode="sz399001"
name="深证成指"
height="220px"
/>
{/* 创业板指 + 涨跌分布 */}
<GemIndexCard
indexCode="sz399006"
name="创业板指"
riseCount={marketStats.riseCount || 2156}
fallCount={marketStats.fallCount || 2034}
flatCount={marketStats.flatCount || 312}
height="220px"
/>
</Grid>
</Box>
);
};
export default MarketOverview;

View File

@@ -0,0 +1,34 @@
// AI平台能力统计组件 - 底部横条
import React from 'react';
import { Box, HStack, Divider } from '@chakra-ui/react';
import { StatItem } from './atoms';
import { THEME } from '../constants';
const PlatformStats = ({ stats = [] }) => {
return (
<Box
borderRadius="xl"
py={4}
px={6}
>
<HStack justify="space-around" divider={
<Divider
orientation="vertical"
h="40px"
borderColor="rgba(212, 175, 55, 0.2)"
/>
}>
{stats.map((stat, index) => (
<StatItem
key={index}
icon={stat.icon}
value={stat.value}
label={stat.label}
/>
))}
</HStack>
</Box>
);
};
export default PlatformStats;

View File

@@ -0,0 +1,179 @@
// 交易日历组件
import React, { useState, useMemo } from 'react';
import {
Box,
Text,
VStack,
HStack,
Grid,
GridItem,
IconButton,
} from '@chakra-ui/react';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { DayCell } from './atoms';
import { THEME, WEEKDAY_LABELS } from '../constants';
const TradingCalendar = ({ tradingDays = [] }) => {
const [currentDate, setCurrentDate] = useState(new Date());
const calendarData = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 当月第一天
const firstDay = new Date(year, month, 1);
// 当月最后一天
const lastDay = new Date(year, month + 1, 0);
// 第一天是星期几
const startWeekday = firstDay.getDay();
// 当月天数
const daysInMonth = lastDay.getDate();
// 上月最后几天
const prevMonthLastDay = new Date(year, month, 0).getDate();
const days = [];
// 填充上月日期
for (let i = startWeekday - 1; i >= 0; i--) {
days.push({
day: prevMonthLastDay - i,
isCurrentMonth: false,
isWeekend: false,
isTrading: false,
});
}
// 填充当月日期
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const weekday = date.getDay();
const isWeekend = weekday === 0 || weekday === 6;
const isToday =
day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear();
// 检查是否为交易日(简化逻辑:非周末即交易日)
// 实际应用中应该从 tradingDays 数组判断
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isTrading = tradingDays.length > 0
? tradingDays.includes(dateStr)
: !isWeekend;
days.push({
day,
isCurrentMonth: true,
isWeekend,
isTrading,
isToday,
});
}
// 填充下月日期(补满 6 行 * 7 天 = 42 格)
const remaining = 42 - days.length;
for (let day = 1; day <= remaining; day++) {
days.push({
day,
isCurrentMonth: false,
isWeekend: false,
isTrading: false,
});
}
return days;
}, [currentDate, tradingDays]);
const handlePrevMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
};
const handleNextMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
};
const monthText = `${currentDate.getFullYear()}${currentDate.getMonth() + 1}`;
return (
<Box
borderRadius="xl"
p={4}
h="100%"
>
<VStack align="stretch" spacing={3}>
{/* 日历头部 */}
<HStack justify="space-between">
<HStack spacing={2}>
<Calendar size={16} color={THEME.text.gold} />
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
交易日历
</Text>
</HStack>
<HStack spacing={1}>
<IconButton
icon={<ChevronLeft size={16} />}
size="xs"
variant="ghost"
color="rgba(255, 255, 255, 0.6)"
onClick={handlePrevMonth}
aria-label="上月"
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
/>
<Text fontSize="xs" color="rgba(255, 255, 255, 0.9)" minW="70px" textAlign="center">
{monthText}
</Text>
<IconButton
icon={<ChevronRight size={16} />}
size="xs"
variant="ghost"
color="rgba(255, 255, 255, 0.6)"
onClick={handleNextMonth}
aria-label="下月"
_hover={{ bg: 'rgba(212, 175, 55, 0.15)' }}
/>
</HStack>
</HStack>
{/* 星期标题 */}
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
{WEEKDAY_LABELS.map((label, index) => (
<GridItem key={label}>
<Text
fontSize="xs"
color="rgba(255, 255, 255, 0.5)"
textAlign="center"
py={1}
>
{label}
</Text>
</GridItem>
))}
</Grid>
{/* 日期网格 */}
<Grid templateColumns="repeat(7, 1fr)" gap={0}>
{calendarData.map((dayData, index) => (
<GridItem
key={index}
display="flex"
justifyContent="center"
alignItems="center"
py={0.5}
>
<DayCell
day={dayData.day}
isTrading={dayData.isTrading}
isToday={dayData.isToday || false}
isWeekend={dayData.isWeekend}
isCurrentMonth={dayData.isCurrentMonth}
/>
</GridItem>
))}
</Grid>
</VStack>
</Box>
);
};
export default TradingCalendar;

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