Compare commits

...

64 Commits

Author SHA1 Message Date
zdl
a446f71c04 feat: 添加 pre-commit hook 检查代码规范
- 新增文件必须使用 TypeScript (.ts/.tsx)
- 禁止使用 fetch,提示使用 axios
- 安装 husky 和 lint-staged 依赖

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 19:19:18 +08:00
zdl
e02cbcd9b7 feat(性能监控): 补全 T0 标记 + PostHog 上报
- index.js: 添加 html-loaded 标记(T0 监控点)
- performanceMonitor.ts: 调用 reportPerformanceMetrics 上报到 PostHog
- 现在完整监控 T0-T5 全部阶段并上报性能指标

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 18:29:35 +08:00
zdl
9bb9eab922 fix(MSW): Bytedesk 添加 mock 数据响应
- 未读消息数量返回 { count: 0 }
- 其他 API 返回通用成功响应
- 解决 mock 模式下 404 错误

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:34:44 +08:00
zdl
ada9f6e778 chore(StockQuoteCard): 删除未使用的 mockData.ts
- mockStockQuoteData 未被任何地方引用
- 数据现在通过 useStockQuoteData hook 从 API 获取

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:01:42 +08:00
120 changed files with 9997 additions and 3652 deletions

110
.husky/pre-commit Executable file
View File

@@ -0,0 +1,110 @@
#!/bin/sh
# ============================================
# Git Pre-commit Hook
# ============================================
# 规则:
# 1. src 目录下新增的代码文件必须使用 TypeScript (.ts/.tsx)
# 2. 修改的代码不能使用 fetch应使用 axios
# ============================================
# 颜色定义
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
has_error=0
echo ""
echo "🔍 正在检查代码规范..."
echo ""
# ============================================
# 规则 1: 新文件必须使用 TypeScript
# ============================================
# 获取新增的文件(只检查 src 目录下的代码文件)
new_js_files=$(git diff --cached --name-only --diff-filter=A | grep -E '^src/.*\.(js|jsx)$' || true)
if [ -n "$new_js_files" ]; then
echo "${RED}❌ 错误: 发现新增的 JavaScript 文件${NC}"
echo "${YELLOW} 新文件必须使用 TypeScript (.ts/.tsx)${NC}"
echo ""
echo " 以下文件需要改为 TypeScript:"
echo "$new_js_files" | while read file; do
echo " - $file"
done
echo ""
echo " 💡 提示: 请将文件扩展名改为 .ts 或 .tsx"
echo ""
has_error=1
fi
# ============================================
# 规则 2: 禁止使用 fetch应使用 axios
# ============================================
# 获取所有暂存的文件(新增 + 修改)
staged_files=$(git diff --cached --name-only --diff-filter=AM | grep -E '^src/.*\.(js|jsx|ts|tsx)$' || true)
if [ -n "$staged_files" ]; then
# 检查暂存内容中是否包含 fetch 调用
# 使用 git diff --cached 检查实际修改的内容
fetch_found=""
for file in $staged_files; do
# 检查该文件暂存的更改中是否有 fetch 调用
# 排除注释和字符串中的 fetch
# 匹配: fetch(, await fetch, .fetch(
fetch_matches=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*[^a-zA-Z_]fetch\s*\(' | grep -v '^\+\s*//' || true)
if [ -n "$fetch_matches" ]; then
fetch_found="$fetch_found
$file"
fi
done
if [ -n "$fetch_found" ]; then
echo "${RED}❌ 错误: 检测到使用了 fetch API${NC}"
echo "${YELLOW} 请使用 axios 进行 HTTP 请求${NC}"
echo ""
echo " 以下文件包含 fetch 调用:"
echo "$fetch_found" | while read file; do
if [ -n "$file" ]; then
echo " - $file"
fi
done
echo ""
echo " 💡 修改建议:"
echo " ${GREEN}// 替换前${NC}"
echo " fetch('/api/data').then(res => res.json())"
echo ""
echo " ${GREEN}// 替换后${NC}"
echo " import axios from 'axios';"
echo " axios.get('/api/data').then(res => res.data)"
echo ""
has_error=1
fi
fi
# ============================================
# 检查结果
# ============================================
if [ $has_error -eq 1 ]; then
echo "${RED}========================================${NC}"
echo "${RED}提交被阻止,请修复以上问题后重试${NC}"
echo "${RED}========================================${NC}"
echo ""
exit 1
fi
echo "${GREEN}✅ 代码规范检查通过${NC}"
echo ""
# 运行 lint-staged如果配置了
# 可选:在 package.json 中添加 "lint-staged" 配置来启用代码格式化
# if [ -f "package.json" ] && grep -q '"lint-staged"' package.json; then
# npx lint-staged
# fi

View File

@@ -131,12 +131,14 @@
"eslint-plugin-prettier": "3.4.0",
"gulp": "4.0.2",
"gulp-append-prepend": "1.0.9",
"husky": "^9.1.7",
"imagemin": "^9.0.1",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-pngquant": "^10.0.0",
"kill-port": "^2.0.1",
"less": "^4.4.2",
"less-loader": "^12.3.0",
"lint-staged": "^16.2.7",
"msw": "^2.11.5",
"prettier": "2.2.1",
"react-error-overlay": "6.0.9",

View File

@@ -28,6 +28,7 @@ import {
Icon,
HStack,
Text,
Spacer,
} from '@chakra-ui/react';
import type { ComponentType } from 'react';
import type { IconType } from 'react-icons';
@@ -95,6 +96,8 @@ export interface SubTabContainerProps {
contentPadding?: number;
/** 是否懒加载 */
isLazy?: boolean;
/** TabList 右侧自定义内容 */
rightElement?: React.ReactNode;
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
@@ -107,6 +110,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
theme: customTheme,
contentPadding = 4,
isLazy = true,
rightElement,
}) => {
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
@@ -114,6 +118,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
// 当前索引
const currentIndex = controlledIndex ?? internalIndex;
// 记录已访问的 Tab 索引(用于真正的懒加载)
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
() => new Set([controlledIndex ?? defaultIndex])
);
// 合并主题
const theme: SubTabTheme = {
...THEME_PRESETS[themePreset],
@@ -128,6 +137,12 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey);
// 记录已访问的 Tab用于懒加载
setVisitedTabs(prev => {
if (prev.has(newIndex)) return prev;
return new Set(prev).add(newIndex);
});
if (controlledIndex === undefined) {
setInternalIndex(newIndex);
}
@@ -148,19 +163,27 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
borderBottom="1px solid"
borderColor={theme.borderColor}
pl={0}
pr={4}
py={2}
flexWrap="wrap"
gap={2}
pr={2}
py={1.5}
flexWrap="nowrap"
gap={1}
alignItems="center"
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={theme.tabUnselectedColor}
borderRadius="full"
px={4}
py={2}
fontSize="sm"
px={2.5}
py={1.5}
fontSize="xs"
whiteSpace="nowrap"
flexShrink={0}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
@@ -170,20 +193,31 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
bg: theme.tabHoverBg,
}}
>
<HStack spacing={2}>
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
<HStack spacing={1}>
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
{rightElement && (
<>
<Spacer />
<Box flexShrink={0}>{rightElement}</Box>
</>
)}
</TabList>
<TabPanels p={contentPadding}>
{tabs.map((tab) => {
{tabs.map((tab, idx) => {
const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
return (
<TabPanel key={tab.key} p={0}>
{Component ? <Component {...componentProps} /> : null}
{shouldRender && Component ? (
<Component {...componentProps} />
) : null}
</TabPanel>
);
})}

View File

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

View File

@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
// ⚡ 性能监控:在应用启动时尽早标记
import { performanceMonitor } from './utils/performanceMonitor';
// T0: HTML 加载完成时间点
if (document.readyState === 'complete') {
performanceMonitor.mark('html-loaded');
} else {
window.addEventListener('load', () => {
performanceMonitor.mark('html-loaded');
});
}
// T1: React 开始初始化
performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效

View File

@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
// 搜索 related_stocks 中的股票名称和代码
(e.related_stocks && e.related_stocks.some(stock =>
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
)) ||
// 搜索行业
(e.industry && e.industry.toLowerCase().includes(query))
);
// 如果搜索结果为空,返回所有事件(宽松模式)
if (filteredEvents.length === 0) {
filteredEvents = allEvents;
}
}
// 行业筛选
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
nodeName = industryStock.stock_name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
stock_name: stock.stock_name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
stock_name: randomStock.stock_name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}

View File

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

View File

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

View File

@@ -119,9 +119,12 @@ export const eventHandlers = [
try {
const result = generateMockEvents(params);
// 返回格式兼容 NewsPanel 期望的结构
// NewsPanel 期望: { success, data: [], pagination: {} }
return HttpResponse.json({
success: true,
data: result,
data: result.events, // 事件数组
pagination: result.pagination, // 分页信息
message: '获取成功'
});
} catch (error) {
@@ -135,16 +138,14 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_prev: false,
has_next: false
}
},
{ status: 500 }

View File

@@ -341,6 +341,68 @@ export const stockHandlers = [
}
}),
// 获取股票业绩预告
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
const stockName = stockInfo?.name || `股票${stockCode}`;
// 业绩预告类型列表
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
// 生成业绩预告数据
const forecasts = [
{
forecast_type: '预增',
report_date: '2024年年报',
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元同比增长10%至17%。`,
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
change_range: {
lower: 10,
upper: 17
},
publish_date: '2024-10-15'
},
{
forecast_type: '略增',
report_date: '2024年三季报',
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元同比增长5%至12%。`,
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
change_range: {
lower: 5,
upper: 12
},
publish_date: '2024-07-12'
},
{
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
report_date: '2024年中报',
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
change_range: {
lower: 3,
upper: 8
},
publish_date: '2024-04-20'
}
];
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
forecasts: forecasts
}
});
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
@@ -421,7 +483,19 @@ export const stockHandlers = [
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || []
index_tags: industryInfo.index_tags || [],
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
// 主力动态
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
};
});

View File

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

View File

@@ -1,6 +1,6 @@
# Company 目录结构说明
> 最后更新2025-12-12
> 最后更新2025-12-17API 接口清单梳理)
## 目录结构
@@ -11,24 +11,40 @@ src/views/Company/
├── components/ # UI 组件
│ │
│ ├── LoadingState.tsx # 通用加载状态组件
│ │
│ ├── CompanyHeader/ # 页面头部
│ │ ├── index.js # 组合导出
│ │ └── SearchBar.js # 股票搜索栏
│ │
│ ├── CompanyTabs/ # Tab 切换容器
│ │ ── index.js # Tab 容器(状态管理 + 内容渲染)
│ │ └── TabNavigation.js # Tab 导航栏
│ │ ── index.js # Tab 容器(状态管理 + 内容渲染)
│ │
│ ├── StockQuoteCard/ # 股票行情卡片TypeScript
│ │ ├── index.tsx # 主组件
│ ├── StockQuoteCard/ # 股票行情卡片TypeScript,数据已下沉
│ │ ├── index.tsx # 主组件Props 从 11 个精简为 4 个)
│ │ ├── types.ts # 类型定义
│ │ ── mockData.ts # Mock 数据
│ │ ── mockData.ts # Mock 数据
│ │ ├── hooks/ # 内部数据 Hooks2025-12-17 新增)
│ │ │ ├── index.ts # hooks 导出索引
│ │ │ ├── useStockQuoteData.ts # 行情数据+基本信息获取
│ │ │ └── useStockCompare.ts # 股票对比逻辑
│ │ └── components/ # 子组件
│ │ ├── index.ts # 组件导出
│ │ ├── theme.ts # 主题配置
│ │ ├── formatters.ts # 格式化工具
│ │ ├── StockHeader.tsx # 股票头部(名称、代码、收藏按钮)
│ │ ├── PriceDisplay.tsx # 价格显示组件
│ │ ├── CompanyInfo.tsx # 公司信息(行业、市值等)
│ │ ├── KeyMetrics.tsx # 关键指标PE、PB、换手率等
│ │ ├── MainForceInfo.tsx # 主力资金信息
│ │ ├── SecondaryQuote.tsx # 副行情(对比股票)
│ │ ├── CompareStockInput.tsx # 对比股票输入
│ │ └── StockCompareModal.tsx # 股票对比弹窗
│ │
│ ├── CompanyOverview/ # Tab: 公司概览TypeScript
│ │ ├── index.tsx # 主组件(组合层)
│ │ ├── types.ts # 类型定义
│ │ ├── utils.ts # 格式化工具
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab21 个 TS 文件)
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
│ │ │
│ │ ├── hooks/ # 数据 Hooks
@@ -47,29 +63,69 @@ src/views/Company/
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
│ │ │ └── ShareholdersTable.tsx # 股东表格
│ │ │
│ │ ── BasicInfoTab/ # 基本信息 Tab可配置化
│ │ ├── index.tsx # 主组件(可配置)
│ │ ├── config.ts # Tab 配置 + 黑金主题
│ │ ├── utils.ts # 格式化工具函数
│ │ └── components/ # 子组件
│ │ ├── index.ts # 组件统一导出
│ │ ├── LoadingState.tsx # 加载状态组件
│ │ ├── ShareholderPanel.tsx # 股权结构面板
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
│ │ ├── BranchesPanel.tsx # 分支机构面板
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
│ │ └── management/ # 管理团队模块
│ │ ├── index.ts # 模块导出
│ │ ├── types.ts # 类型定义
│ │ ├── ManagementPanel.tsx # 主组件useMemo
│ │ ├── CategorySection.tsx # 分类区块memo
│ │ └── ManagementCard.tsx # 人员卡片memo
│ │ ── BasicInfoTab/ # 基本信息 Tab可配置化
│ │ ├── index.tsx # 主组件(可配置)
│ │ ├── config.ts # Tab 配置 + 黑金主题
│ │ ├── utils.ts # 格式化工具函数
│ │ └── components/ # 子组件
│ │ ├── index.ts # 组件统一导出
│ │ ├── LoadingState.tsx # 加载状态组件
│ │ ├── ShareholderPanel.tsx # 股权结构面板
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
│ │ ├── BranchesPanel.tsx # 分支机构面板
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
│ │ └── management/ # 管理团队模块
│ │ ├── index.ts # 模块导出
│ │ ├── types.ts # 类型定义
│ │ ├── ManagementPanel.tsx # 主组件useMemo
│ │ ├── CategorySection.tsx # 分类区块memo
│ │ └── ManagementCard.tsx # 人员卡片memo
│ │ │
│ │ └── DeepAnalysisTab/ # 深度分析 Tab原子设计模式
│ │ ├── index.tsx # 主入口组件
│ │ ├── types.ts # 类型定义
│ │ ├── atoms/ # 原子组件
│ │ │ ├── index.ts
│ │ │ ├── DisclaimerBox.tsx # 免责声明
│ │ │ ├── ScoreBar.tsx # 评分进度条
│ │ │ ├── BusinessTreeItem.tsx # 业务树形项
│ │ │ ├── KeyFactorCard.tsx # 关键因素卡片
│ │ │ ├── ProcessNavigation.tsx # 流程导航
│ │ │ └── ValueChainFilterBar.tsx # 产业链筛选栏
│ │ ├── components/ # Card 组件
│ │ │ ├── index.ts
│ │ │ ├── CorePositioningCard/ # 核心定位卡片(含 atoms
│ │ │ │ ├── index.tsx
│ │ │ │ ├── theme.ts
│ │ │ │ └── atoms/
│ │ │ ├── CompetitiveAnalysisCard.tsx
│ │ │ ├── BusinessStructureCard.tsx
│ │ │ ├── BusinessSegmentsCard.tsx
│ │ │ ├── ValueChainCard.tsx
│ │ │ ├── KeyFactorsCard.tsx
│ │ │ ├── TimelineCard.tsx
│ │ │ └── StrategyAnalysisCard.tsx
│ │ ├── organisms/ # 复杂交互组件
│ │ │ ├── ValueChainNodeCard/
│ │ │ │ ├── index.tsx
│ │ │ │ └── RelatedCompaniesModal.tsx
│ │ │ └── TimelineComponent/
│ │ │ ├── index.tsx
│ │ │ └── EventDetailModal.tsx
│ │ ├── tabs/ # Tab 面板
│ │ │ ├── index.ts
│ │ │ ├── BusinessTab.tsx
│ │ │ ├── DevelopmentTab.tsx
│ │ │ ├── StrategyTab.tsx
│ │ │ └── ValueChainTab.tsx
│ │ └── utils/
│ │ └── chartOptions.ts
│ │
│ ├── MarketDataView/ # Tab: 股票行情TypeScript
│ │ ├── index.tsx # 主组件入口~285 行Tab 容器)
│ │ ├── index.tsx # 主组件入口
│ │ ├── types.ts # 类型定义
│ │ ├── constants.ts # 主题配置、常量
│ │ ├── constants.ts # 主题配置(含黑金主题 darkGoldTheme
│ │ ├── services/
│ │ │ └── marketService.ts # API 服务层
│ │ ├── hooks/
@@ -81,53 +137,92 @@ src/views/Company/
│ │ ├── index.ts # 组件导出
│ │ ├── ThemedCard.tsx # 主题化卡片
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
│ │ ├── AnalysisModal.tsx # 涨幅分析模态框
│ │ ├── StockSummaryCard/ # 股票概览卡片(黑金主题 4 列布局)
│ │ │ ├── index.tsx
│ │ │ ├── StockHeaderCard.tsx
│ │ │ ├── MetricCard.tsx
│ │ │ ├── utils.ts
│ │ │ └── atoms/
│ │ │ ├── index.ts
│ │ │ ├── DarkGoldCard.tsx
│ │ │ ├── CardTitle.tsx
│ │ │ ├── MetricValue.tsx
│ │ │ ├── PriceDisplay.tsx
│ │ │ └── StatusTag.tsx
│ │ └── panels/ # Tab 面板组件
│ │ ├── index.ts # 面板组件统一导出
│ │ ├── TradeDataPanel.tsx # 交易数据K线图、分钟图、表格
│ │ ├── FundingPanel.tsx # 融资融券面板
│ │ ├── BigDealPanel.tsx # 大宗交易面板
│ │ ├── UnusualPanel.tsx # 龙虎榜面板
│ │ ── PledgePanel.tsx # 股权质押面板
│ │ ├── index.ts
│ │ ├── TradeDataPanel/
│ │ │ ├── index.tsx
│ │ │ └── KLineModule.tsx
│ │ ├── FundingPanel.tsx
│ │ ── BigDealPanel.tsx
│ │ ├── UnusualPanel.tsx
│ │ └── PledgePanel.tsx
│ │
│ ├── DeepAnalysis/ # Tab: 深度分析
│ ├── DeepAnalysis/ # Tab: 深度分析(入口)
│ │ └── index.js
│ │
│ ├── DynamicTracking/ # Tab: 动态跟踪
│ │ ── index.js
│ │ ── index.js # 主组件
│ │ └── components/
│ │ ├── index.js # 组件导出
│ │ ├── NewsPanel.js # 新闻面板
│ │ └── ForecastPanel.js # 业绩预告面板
│ │
│ ├── FinancialPanorama/ # Tab: 财务全景TypeScript 模块化)
│ │ ├── index.tsx # 主组件入口~400 行)
│ │ ├── index.tsx # 主组件入口
│ │ ├── types.ts # TypeScript 类型定义
│ │ ├── constants.ts # 常量配置(颜色、指标定义)
│ │ ├── hooks/
│ │ │ ├── index.ts # Hook 统一导出
│ │ │ └── useFinancialData.ts # 财务数据加载 Hook
│ │ │ ├── index.ts
│ │ │ └── useFinancialData.ts
│ │ ├── utils/
│ │ │ ├── index.ts # 工具函数统一导出
│ │ │ ├── calculations.ts # 计算工具(同比变化、单元格颜色)
│ │ │ └── chartOptions.ts # ECharts 图表配置生成器
│ │ │ ├── index.ts
│ │ │ ├── calculations.ts
│ │ │ └── chartOptions.ts
│ │ ├── tabs/ # Tab 面板组件
│ │ │ ├── index.ts
│ │ │ ├── BalanceSheetTab.tsx
│ │ │ ├── CashflowTab.tsx
│ │ │ ├── FinancialMetricsTab.tsx
│ │ │ ├── IncomeStatementTab.tsx
│ │ │ └── MetricsCategoryTab.tsx
│ │ └── components/
│ │ ├── index.ts # 组件统一导出
│ │ ├── StockInfoHeader.tsx # 股票信息头部
│ │ ├── BalanceSheetTable.tsx # 资产负债表
│ │ ├── IncomeStatementTable.tsx # 利润表
│ │ ├── CashflowTable.tsx # 现金流量表
│ │ ├── FinancialMetricsTable.tsx # 财务指标表
│ │ ├── MainBusinessAnalysis.tsx # 主营业务分析
│ │ ├── IndustryRankingView.tsx # 行业排名
│ │ ├── StockComparison.tsx # 股票对比
│ │ ── ComparisonAnalysis.tsx # 综合对比分析
│ │ ├── index.ts
│ │ ├── StockInfoHeader.tsx
│ │ ├── FinancialTable.tsx # 通用财务表格
│ │ ├── FinancialOverviewPanel.tsx # 财务概览面板
│ │ ├── KeyMetricsOverview.tsx # 关键指标概览
│ │ ├── PeriodSelector.tsx # 期数选择器
│ │ ├── BalanceSheetTable.tsx
│ │ ├── IncomeStatementTable.tsx
│ │ ├── CashflowTable.tsx
│ │ ── FinancialMetricsTable.tsx
│ │ ├── MainBusinessAnalysis.tsx
│ │ ├── IndustryRankingView.tsx
│ │ ├── StockComparison.tsx
│ │ └── ComparisonAnalysis.tsx
│ │
│ └── ForecastReport/ # Tab: 盈利预测(待拆分
── index.js
│ └── ForecastReport/ # Tab: 盈利预测(TypeScript已模块化
── index.tsx # 主组件入口
│ ├── types.ts # 类型定义
│ ├── constants.ts # 配色、图表配置常量
│ └── components/
│ ├── index.ts
│ ├── ChartCard.tsx # 图表卡片容器
│ ├── IncomeProfitGrowthChart.tsx # 营收与利润趋势图
│ ├── IncomeProfitChart.tsx # 营收利润图(备用)
│ ├── GrowthChart.tsx # 增长率图(备用)
│ ├── EpsChart.tsx # EPS 趋势图
│ ├── PePegChart.tsx # PE/PEG 分析图
│ └── DetailTable.tsx # 详细数据表格
├── hooks/ # 页面级 Hooks
│ ├── useCompanyStock.js # 股票代码管理URL 同步)
│ ├── useCompanyWatchlist.js # 自选股管理Redux 集成)
── useCompanyEvents.js # PostHog 事件追踪
└── useStockQuote.js # 股票行情数据 Hook
── useCompanyEvents.js # PostHog 事件追踪
# 注:useStockQuote.js 已下沉到 StockQuoteCard/hooks/useStockQuoteData.ts
└── constants/ # 常量定义
└── index.js # Tab 配置、Toast 消息、默认值
@@ -135,19 +230,101 @@ src/views/Company/
---
## API 接口清单
Company 模块共使用 **27 个** API 接口(去重后)。
### 一、股票基础信息 (8 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/stock/${stockCode}/basic-info` | GET | useBasicInfo.ts, useStockQuoteData.ts, NewsPanel.js |
| `/api/stock/${stockCode}/branches` | GET | useBranchesData.ts |
| `/api/stock/${stockCode}/management?active_only=true` | GET | useManagementData.ts |
| `/api/stock/${stockCode}/announcements?limit=20` | GET | useAnnouncementsData.ts |
| `/api/stock/${stockCode}/disclosure-schedule` | GET | useDisclosureData.ts |
| `/api/stock/${stockCode}/forecast` | GET | ForecastPanel.js |
| `/api/stock/${stockCode}/forecast-report` | GET | ForecastReport/index.tsx |
| `/api/stock/${stockCode}/latest-minute` | GET | marketService.ts |
### 二、股东信息 (4 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/stock/${stockCode}/actual-control` | GET | useShareholderData.ts |
| `/api/stock/${stockCode}/concentration` | GET | useShareholderData.ts |
| `/api/stock/${stockCode}/top-shareholders?limit=10` | GET | useShareholderData.ts |
| `/api/stock/${stockCode}/top-circulation-shareholders?limit=10` | GET | useShareholderData.ts |
### 三、行情数据 (8 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/stock/quotes` | POST | stockService.getQuotes |
| `/api/market/summary/${stockCode}` | GET | marketService.ts |
| `/api/market/trade/${stockCode}?days=${days}` | GET | marketService.ts |
| `/api/market/funding/${stockCode}?days=${days}` | GET | marketService.ts |
| `/api/market/bigdeal/${stockCode}?days=${days}` | GET | marketService.ts |
| `/api/market/unusual/${stockCode}?days=${days}` | GET | marketService.ts |
| `/api/market/pledge/${stockCode}` | GET | marketService.ts |
| `/api/market/rise-analysis/${stockCode}` | GET | marketService.ts |
### 四、深度分析 (5 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/company/comprehensive-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
| `/api/company/value-chain-analysis/${stockCode}` | GET | DeepAnalysis/index.js |
| `/api/company/key-factors-timeline/${stockCode}` | GET | DeepAnalysis/index.js |
| `/api/company/value-chain/related-companies?node_name=...` | GET | ValueChainNodeCard/index.tsx |
| `/api/financial/industry-rank/${stockCode}` | GET | DeepAnalysis/index.js |
### 五、财务数据 (1 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/financial/financial-metrics/${stockCode}?limit=${limit}` | GET | financialService.getFinancialMetrics |
### 六、事件/新闻 (1 个)
| 接口 | 方法 | 调用位置 |
|------|------|----------|
| `/api/events?q=${searchTerm}&page=${page}&per_page=10` | GET | NewsPanel.js |
### 统计汇总
| 分类 | 数量 |
|------|------|
| 股票基础信息 | 8 |
| 股东信息 | 4 |
| 行情数据 | 8 |
| 深度分析 | 5 |
| 财务数据 | 1 |
| 事件/新闻 | 1 |
| **去重后总计** | **27** |
> 注:`/api/stock/${stockCode}/basic-info` 在 3 处调用,但只算 1 个接口。
---
## 文件职责说明
### 入口文件
#### `index.js` - 页面入口
- **职责**:纯组合层,协调 Hooks 和 Components
- **代码行数**95 行
- **代码行数**~105 行2025-12-17 优化后精简)
- **依赖**
- `useCompanyStock` - 股票代码状态
- `useCompanyWatchlist` - 自选股状态
- `useCompanyEvents` - 事件追踪
- `CompanyHeader` - 页面头部
- `StockQuoteCard` - 股票行情卡片(内部自行获取数据)
- `CompanyTabs` - Tab 切换区
- **已移除**2025-12-17
- `useStockQuote` - 已下沉到 StockQuoteCard
- `useBasicInfo` - 已下沉到 StockQuoteCard
- 股票对比逻辑 - 已下沉到 StockQuoteCard
---
@@ -834,4 +1011,243 @@ MarketDataView/components/panels/
**设计原则**
- **职责分离**:主组件只负责 Tab 容器和状态管理
- **组件复用**:面板组件可独立测试和维护
- **类型安全**:每个面板组件有独立的 Props 类型定义
- **类型安全**:每个面板组件有独立的 Props 类型定义
### 2025-12-16 StockSummaryCard 黑金主题重构
**改动概述**
- `StockSummaryCard.tsx` 从单文件重构为**原子设计模式**的目录结构
- 布局从 **1+3**(头部+三卡片)改为 **4 列横向排列**
- 新增**黑金主题**`darkGoldTheme`
- 提取 **5 个原子组件** + **2 个业务组件**
**拆分后文件结构**
```
StockSummaryCard/
├── index.tsx # 主组件4 列 SimpleGrid 布局)
├── StockHeaderCard.tsx # 股票信息卡片(名称、价格、涨跌幅、走势)
├── MetricCard.tsx # 指标卡片模板组件
├── utils.ts # 状态计算工具函数
└── atoms/ # 原子组件
├── index.ts # 统一导出
├── DarkGoldCard.tsx # 黑金主题卡片容器(渐变背景、金色边框)
├── CardTitle.tsx # 卡片标题(图标+标题+副标题)
├── MetricValue.tsx # 核心数值展示(标签+数值+后缀)
├── PriceDisplay.tsx # 价格显示(价格+涨跌箭头+百分比)
└── StatusTag.tsx # 状态标签(活跃/健康/警惕等)
```
**4 列布局设计**
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 股票信息 │ │ 交易热度 │ │ 估值VS安全 │ │ 情绪与风险 │
│ 平安银行 │ │ (流动性) │ │ (便宜否) │ │ (资金面) │
│ (000001) │ │ │ │ │ │ │
│ 13.50 ↗+1.89%│ │ 成交额 46.79亿│ │ PE 4.96 │ │ 融资 58.23亿 │
│ 走势:小幅上涨 │ │ 成交量|换手率 │ │ 质押率(健康) │ │ 融券 1.26亿 │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
**黑金主题配置**`constants.ts`
```typescript
export const darkGoldTheme = {
bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)',
border: 'rgba(212, 175, 55, 0.3)',
gold: '#D4AF37',
orange: '#FF9500',
green: '#00C851',
red: '#FF4444',
textPrimary: '#FFFFFF',
textMuted: 'rgba(255, 255, 255, 0.6)',
};
```
**状态计算工具**`utils.ts`
| 函数 | 功能 |
|------|------|
| `getTrendDescription` | 根据涨跌幅返回走势描述(强势上涨/小幅下跌等) |
| `getTurnoverStatus` | 换手率状态≥3% 活跃, ≥1% 正常, <1% 冷清) |
| `getPEStatus` | 市盈率估值评级(极低估值/合理/偏高/泡沫风险) |
| `getPledgeStatus` | 质押率健康状态(<10% 健康, <30% 正常, <50% 偏高, ≥50% 警惕) |
| `getPriceColor` | 根据涨跌返回颜色(红涨绿跌) |
**原子组件说明**
| 组件 | 行数 | 用途 | 可复用场景 |
|------|------|------|-----------|
| `DarkGoldCard` | ~40 | 黑金主题卡片容器 | 任何需要黑金风格的卡片 |
| `CardTitle` | ~30 | 卡片标题行 | 带图标的标题展示 |
| `MetricValue` | ~45 | 核心数值展示 | 各种指标数值展示 |
| `PriceDisplay` | ~55 | 价格+涨跌幅 | 股票价格展示 |
| `StatusTag` | ~20 | 状态标签 | 各种状态文字标签 |
**响应式断点**
- `lg` (≥992px): 4 列
- `md` (≥768px): 2 列
- `base` (<768px): 1 列
**类型定义更新**`types.ts`
- `StockSummaryCardProps.theme` 改为可选参数,组件内置使用 `darkGoldTheme`
**优化效果**
| 指标 | 优化前 | 优化后 | 改善 |
|------|--------|--------|------|
| 主文件行数 | ~350 | ~115 | -67% |
| 文件数量 | 1 | 8 | 原子设计模式 |
| 可复用组件 | 0 | 5 原子 + 2 业务 | 提升 |
| 主题支持 | 依赖传入 | 内置黑金主题 | 独立 |
**设计原则**
- **原子设计模式**atoms基础元素→ 业务组件MetricCard、StockHeaderCard→ 页面组件index.tsx
- **主题独立**StockSummaryCard 使用内置黑金主题,不依赖外部传入
- **职责分离**:状态计算逻辑提取到 `utils.ts`UI 与逻辑解耦
- **组件复用**:原子组件可在其他黑金主题场景复用
### 2025-12-16 TradeDataPanel 原子设计模式拆分
**改动概述**
- `TradeDataPanel.tsx` 从 **382 行** 拆分为 **8 个 TypeScript 文件**
- 采用**原子设计模式**组织代码
- 提取 **3 个原子组件** + **3 个业务组件**
**拆分后文件结构**
```
TradeDataPanel/
├── index.tsx # 主入口组件(~50 行,组合 3 个子组件)
├── KLineChart.tsx # 日K线图组件~40 行)
├── MinuteKLineSection.tsx # 分钟K线区域~95 行,含加载/空状态处理)
├── TradeTable.tsx # 交易明细表格(~75 行)
└── atoms/ # 原子组件
├── index.ts # 统一导出
├── MinuteStats.tsx # 分钟数据统计(~80 行4 个 Stat 卡片)
├── TradeAnalysis.tsx # 成交分析(~65 行,活跃时段/平均价格等)
└── EmptyState.tsx # 空状态组件(~35 行,可复用)
```
**组件依赖关系**
```
index.tsx
├── KLineChart # 日K线图ECharts
├── MinuteKLineSection # 分钟K线区域
│ ├── MinuteStats (atom) # 开盘/当前/最高/最低价统计
│ ├── TradeAnalysis (atom) # 成交数据分析
│ └── EmptyState (atom) # 空状态提示
└── TradeTable # 交易明细表格(最近 10 天)
```
**组件职责**
| 组件 | 行数 | 功能 |
|------|------|------|
| `index.tsx` | ~50 | 主入口,组合 3 个子组件 |
| `KLineChart` | ~40 | 日K线图渲染支持图表点击事件 |
| `MinuteKLineSection` | ~95 | 分钟K线区域含加载状态、空状态、统计数据 |
| `TradeTable` | ~75 | 最近 10 天交易明细表格 |
| `MinuteStats` | ~80 | 分钟数据四宫格统计(开盘/当前/最高/最低价) |
| `TradeAnalysis` | ~65 | 成交数据分析(活跃时段、平均价格、数据点数) |
| `EmptyState` | ~35 | 通用空状态组件(可配置标题和描述) |
**优化效果**
| 指标 | 优化前 | 优化后 | 改善 |
|------|--------|--------|------|
| 主文件行数 | 382 | ~50 | -87% |
| 文件数量 | 1 | 8 | 原子设计模式 |
| 可复用组件 | 0 | 3 原子 + 3 业务 | 提升 |
**设计原则**
- **原子设计模式**atomsMinuteStats、TradeAnalysis、EmptyState→ 业务组件KLineChart、MinuteKLineSection、TradeTable→ 主组件
- **职责分离**:图表、统计、表格各自独立
- **组件复用**EmptyState 可在其他场景复用
- **类型安全**:完整的 Props 类型定义和导出
### 2025-12-17 StockQuoteCard 数据下沉优化
**改动概述**
- StockQuoteCard Props 从 **11 个** 精简至 **4 个**(减少 64%
- 行情数据、基本信息、股票对比逻辑全部下沉到组件内部
- Company/index.js 移除约 **40 行** 数据获取代码
- 删除 `Company/hooks/useStockQuote.js`
**创建的文件**
```
StockQuoteCard/hooks/
├── index.ts # hooks 导出索引
├── useStockQuoteData.ts # 行情数据 + 基本信息获取(~152 行)
└── useStockCompare.ts # 股票对比逻辑(~91 行)
```
**Props 对比**
**优化前11 个 Props**
```tsx
<StockQuoteCard
data={quoteData}
isLoading={isQuoteLoading}
basicInfo={basicInfo}
currentStockInfo={currentStockInfo}
compareStockInfo={compareStockInfo}
isCompareLoading={isCompareLoading}
onCompare={handleCompare}
onCloseCompare={handleCloseCompare}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
/>
```
**优化后4 个 Props**
```tsx
<StockQuoteCard
stockCode={stockCode}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
/>
```
**Hook 返回值**
`useStockQuoteData(stockCode)`:
```typescript
{
quoteData: StockQuoteCardData | null; // 行情数据
basicInfo: BasicInfo | null; // 基本信息
isLoading: boolean; // 加载状态
error: string | null; // 错误信息
refetch: () => void; // 手动刷新
}
```
`useStockCompare(stockCode)`:
```typescript
{
currentStockInfo: StockInfo | null; // 当前股票财务信息
compareStockInfo: StockInfo | null; // 对比股票财务信息
isCompareLoading: boolean; // 对比数据加载中
handleCompare: (code: string) => Promise<void>; // 触发对比
clearCompare: () => void; // 清除对比
}
```
**修改的文件**
| 文件 | 操作 | 说明 |
|------|------|------|
| `StockQuoteCard/hooks/useStockQuoteData.ts` | 新建 | 合并行情+基本信息获取 |
| `StockQuoteCard/hooks/useStockCompare.ts` | 新建 | 股票对比逻辑 |
| `StockQuoteCard/hooks/index.ts` | 新建 | hooks 导出索引 |
| `StockQuoteCard/index.tsx` | 修改 | 使用内部 hooks减少 props |
| `StockQuoteCard/types.ts` | 修改 | Props 从 11 个精简为 4 个 |
| `Company/index.js` | 修改 | 移除下沉的数据获取逻辑 |
| `Company/hooks/useStockQuote.js` | 删除 | 已移到 StockQuoteCard |
**优化收益**
| 指标 | 优化前 | 优化后 | 改善 |
|------|--------|--------|------|
| Props 数量 | 11 | 4 | -64% |
| Company/index.js 行数 | ~172 | ~105 | -39% |
| 数据获取位置 | 页面层 | 组件内部 | 就近原则 |
| 可复用性 | 依赖父组件 | 独立可用 | 提升 |
**设计原则**
- **数据就近获取**:组件自己获取自己需要的数据
- **Props 最小化**:只传递真正需要外部控制的状态
- **职责清晰**:自选股状态保留在页面层(涉及 Redux 和事件追踪)
- **可复用性**StockQuoteCard 可独立在其他页面使用

View File

@@ -13,6 +13,7 @@ import {
VStack,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { useStockSearch } from '../../hooks/useStockSearch';
/**
* 股票搜索栏组件(带模糊搜索下拉)
@@ -31,27 +32,18 @@ const SearchBar = ({
}) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState([]);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 模糊搜索过滤
// 使用共享的搜索 Hook
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
// 根据搜索结果更新下拉显示状态
useEffect(() => {
if (inputCode && inputCode.trim()) {
const searchTerm = inputCode.trim().toLowerCase();
const filtered = allStocks.filter(stock =>
stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputCode.trim())
).slice(0, 10); // 限制显示10条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputCode, allStocks]);
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
}, [filteredStocks, inputCode]);
// 点击外部关闭下拉
useEffect(() => {

View File

@@ -8,7 +8,6 @@ import {
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
IconButton,
@@ -23,7 +22,6 @@ import {
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { FaBullhorn } from "react-icons/fa";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
@@ -55,10 +53,6 @@ const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) =>
<VStack spacing={4} align="stretch">
{/* 最新公告 */}
<Box>
<HStack mb={3}>
<Icon as={FaBullhorn} color={THEME.gold} />
<Text fontWeight="bold" color={THEME.textPrimary}></Text>
</HStack>
<VStack spacing={2} align="stretch">
{announcements.map((announcement: any, idx: number) => (
<Card

View File

@@ -12,15 +12,27 @@ import {
Divider,
Center,
Code,
Spinner,
} from "@chakra-ui/react";
import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo";
interface BusinessInfoPanelProps {
basicInfo: any;
stockCode: string;
}
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ basicInfo }) => {
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
const { basicInfo, loading } = useBasicInfo(stockCode);
if (loading) {
return (
<Center h="200px">
<Spinner size="lg" color={THEME.gold} />
</Center>
);
}
if (!basicInfo) {
return (
<Center h="200px">

View File

@@ -5,15 +5,12 @@ import React from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
SimpleGrid,
} from "@chakra-ui/react";
import { FaCalendarAlt } from "react-icons/fa";
import { useDisclosureData } from "../../hooks/useDisclosureData";
import { THEME } from "../config";
@@ -42,10 +39,6 @@ const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stock
return (
<VStack spacing={4} align="stretch">
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color={THEME.gold} />
<Text fontWeight="bold" color={THEME.textPrimary}></Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.map((schedule: any, idx: number) => (
<Card

View File

@@ -17,7 +17,6 @@ import {
// Props 类型定义
export interface BasicInfoTabProps {
stockCode: string;
basicInfo?: any;
// 可配置项
enabledTabs?: string[]; // 指定显示哪些 Tab通过 key
@@ -59,7 +58,6 @@ const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
*/
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
stockCode,
basicInfo,
enabledTabs,
defaultTabIndex = 0,
onTabChange,
@@ -72,7 +70,7 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
<CardBody p={0}>
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode, basicInfo }}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"

View File

@@ -2,6 +2,7 @@
* 竞争地位分析卡片
*
* 显示竞争力评分、雷达图和竞争分析
* 包含行业排名弹窗功能
*/
import React, { memo, useMemo } from 'react';
@@ -22,6 +23,14 @@ import {
Icon,
Divider,
SimpleGrid,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import {
FaTrophy,
@@ -33,11 +42,32 @@ import {
FaShieldAlt,
FaRocket,
FaUsers,
FaExternalLinkAlt,
} from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { ScoreBar } from '../atoms';
import { getRadarChartOption } from '../utils/chartOptions';
import type { ComprehensiveData, CompetitivePosition } from '../types';
import { IndustryRankingView } from '../../../FinancialPanorama/components';
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
// 黑金主题弹窗样式
const MODAL_STYLES = {
content: {
bg: 'gray.900',
borderColor: 'rgba(212, 175, 55, 0.3)',
borderWidth: '1px',
maxW: '900px',
},
header: {
color: 'yellow.500',
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
borderBottomWidth: '1px',
},
closeButton: {
color: 'yellow.500',
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
},
} as const;
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
@@ -57,6 +87,7 @@ const CHART_STYLE = { height: '320px' } as const;
interface CompetitiveAnalysisCardProps {
comprehensiveData: ComprehensiveData;
industryRankData?: IndustryRankData[];
}
// 竞争对手标签组件
@@ -141,8 +172,10 @@ const AdvantagesSection = memo<AdvantagesSectionProps>(
AdvantagesSection.displayName = 'AdvantagesSection';
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
({ comprehensiveData }) => {
({ comprehensiveData, industryRankData }) => {
const competitivePosition = comprehensiveData.competitive_position;
const { isOpen, onOpen, onClose } = useDisclosure();
if (!competitivePosition) return null;
// 缓存雷达图配置
@@ -160,56 +193,99 @@ const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
[competitivePosition.analysis?.main_competitors]
);
// 判断是否有行业排名数据可展示
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
return (
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
{competitivePosition.ranking && (
<Badge
ml={2}
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
>
{competitivePosition.ranking.industry_rank}/
{competitivePosition.ranking.total_companies}
</Badge>
)}
</HStack>
</CardHeader>
<CardBody>
{/* 主要竞争对手 */}
{/* {competitors.length > 0 && <CompetitorTags competitors={competitors} />} */}
<>
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
{competitivePosition.ranking && (
<Badge
ml={2}
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
cursor={hasIndustryRankData ? 'pointer' : 'default'}
onClick={hasIndustryRankData ? onOpen : undefined}
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
>
{competitivePosition.ranking.industry_rank}/
{competitivePosition.ranking.total_companies}
</Badge>
)}
{hasIndustryRankData && (
<Button
size="xs"
variant="ghost"
color="yellow.500"
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
onClick={onOpen}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
>
</Button>
)}
</HStack>
</CardHeader>
<CardBody>
{/* 主要竞争对手 */}
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
{/* 评分和雷达图 */}
{/* <Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_COLSPAN}>
<ScoreSection scores={competitivePosition.scores} />
</GridItem>
{/* 评分和雷达图 */}
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_COLSPAN}>
<ScoreSection scores={competitivePosition.scores} />
</GridItem>
<GridItem colSpan={GRID_COLSPAN}>
{radarOption && (
<ReactECharts
option={radarOption}
style={CHART_STYLE}
theme="dark"
<GridItem colSpan={GRID_COLSPAN}>
{radarOption && (
<ReactECharts
option={radarOption}
style={CHART_STYLE}
theme="dark"
/>
)}
</GridItem>
</Grid>
<Divider my={4} borderColor="yellow.600" />
{/* 竞争优势和劣势 */}
<AdvantagesSection
advantages={competitivePosition.analysis?.competitive_advantages}
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
/>
</CardBody>
</Card>
{/* 行业排名弹窗 - 黑金主题 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay bg="blackAlpha.700" />
<ModalContent {...MODAL_STYLES.content}>
<ModalHeader {...MODAL_STYLES.header}>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Text></Text>
</HStack>
</ModalHeader>
<ModalCloseButton {...MODAL_STYLES.closeButton} />
<ModalBody py={4}>
{hasIndustryRankData && (
<IndustryRankingView
industryRank={industryRankData}
bgColor="gray.800"
borderColor="rgba(212, 175, 55, 0.3)"
/>
)}
</GridItem>
</Grid> */}
{/* <Divider my={4} borderColor="yellow.600" /> */}
{/* 竞争优势和劣势 */}
<AdvantagesSection
advantages={competitivePosition.analysis?.competitive_advantages}
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
/>
</CardBody>
</Card>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}
);

View File

@@ -155,24 +155,28 @@ const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
align="center"
flexWrap="wrap"
>
{/* 左侧:流程式导航 */}
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
{viewMode === 'hierarchy' && (
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
)}
{/* 右侧:筛选与视图切换 */}
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* 右侧:筛选与视图切换 - 始终靠右 */}
<Box ml="auto">
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
</Flex>
{/* 内容区域 */}

View File

@@ -11,9 +11,10 @@
*/
import React, { useMemo } from 'react';
import { Card, CardBody, Center, VStack, Spinner, Text } from '@chakra-ui/react';
import { Card, CardBody } from '@chakra-ui/react';
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
import LoadingState from '../../LoadingState';
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
@@ -47,6 +48,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
loading,
cardBg,
expandedSegments,
@@ -74,12 +76,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
componentProps={{}}
themePreset="blackGold"
/>
<Center h="200px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text color="gray.400">...</Text>
</VStack>
</Center>
<LoadingState message="加载数据中..." height="200px" />
</CardBody>
</Card>
);
@@ -96,6 +93,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
cardBg,
expandedSegments,
onToggleSegment,

View File

@@ -32,12 +32,10 @@ import {
FaStar,
} from 'react-icons/fa';
import { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig';
import axios from '@utils/axiosConfig';
import RelatedCompaniesModal from './RelatedCompaniesModal';
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
const API_BASE_URL = getApiBase();
// 黑金主题配置
const THEME = {
cardBg: 'gray.700',
@@ -120,12 +118,11 @@ const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
const fetchRelatedCompanies = async () => {
setLoadingRelated(true);
try {
const response = await fetch(
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
const { data } = await axios.get(
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
node.node_name
)}`
);
const data = await response.json();
if (data.success) {
setRelatedCompanies(data.data || []);
} else {

View File

@@ -1,7 +1,7 @@
/**
* 战略分析 Tab
*
* 包含:核心定位 + 战略分析 + 竞争地位分析
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
*/
import React, { memo } from 'react';
@@ -11,15 +11,17 @@ import {
StrategyAnalysisCard,
CompetitiveAnalysisCard,
} from '../components';
import type { ComprehensiveData } from '../types';
import type { ComprehensiveData, IndustryRankData } from '../types';
export interface StrategyTabProps {
comprehensiveData?: ComprehensiveData;
industryRankData?: IndustryRankData[];
cardBg?: string;
}
const StrategyTab: React.FC<StrategyTabProps> = memo(({
comprehensiveData,
industryRankData,
cardBg,
}) => {
return (
@@ -40,9 +42,12 @@ const StrategyTab: React.FC<StrategyTabProps> = memo(({
/>
)}
{/* 竞争地位分析 */}
{/* 竞争地位分析(包含行业排名弹窗) */}
{comprehensiveData?.competitive_position && (
<CompetitiveAnalysisCard comprehensiveData={comprehensiveData} />
<CompetitiveAnalysisCard
comprehensiveData={comprehensiveData}
industryRankData={industryRankData}
/>
)}
</TabPanelContainer>
);

View File

@@ -265,6 +265,35 @@ export interface KeyFactorsData {
development_timeline?: DevelopmentTimeline;
}
// ==================== 行业排名类型 ====================
/** 行业排名指标 */
export interface RankingMetric {
value?: number;
rank?: number;
industry_avg?: number;
}
/** 行业排名数据 */
export interface IndustryRankData {
period: string;
report_type: string;
rankings?: {
industry_name: string;
level_description: string;
metrics?: {
eps?: RankingMetric;
bvps?: RankingMetric;
roe?: RankingMetric;
revenue_growth?: RankingMetric;
profit_growth?: RankingMetric;
operating_margin?: RankingMetric;
debt_ratio?: RankingMetric;
receivable_turnover?: RankingMetric;
};
}[];
}
// ==================== 主组件 Props 类型 ====================
/** Tab 类型 */
@@ -274,6 +303,7 @@ export interface DeepAnalysisTabProps {
comprehensiveData?: ComprehensiveData;
valueChainData?: ValueChainData;
keyFactorsData?: KeyFactorsData;
industryRankData?: IndustryRankData[];
loading?: boolean;
cardBg?: string;
expandedSegments: Record<number, boolean>;

View File

@@ -36,6 +36,58 @@ import {
FaChevronRight,
} from "react-icons/fa";
// 黑金主题配色
const THEME_PRESETS = {
blackGold: {
bg: "#0A0E17",
cardBg: "#1A1F2E",
cardHoverBg: "#212633",
cardBorder: "rgba(212, 175, 55, 0.2)",
cardHoverBorder: "#D4AF37",
textPrimary: "#E8E9ED",
textSecondary: "#A0A4B8",
textMuted: "#6B7280",
gold: "#D4AF37",
goldLight: "#FFD54F",
inputBg: "#151922",
inputBorder: "#2D3748",
buttonBg: "#D4AF37",
buttonText: "#0A0E17",
buttonHoverBg: "#FFD54F",
badgeS: { bg: "rgba(255, 195, 0, 0.2)", color: "#FFD54F" },
badgeA: { bg: "rgba(249, 115, 22, 0.2)", color: "#FB923C" },
badgeB: { bg: "rgba(59, 130, 246, 0.2)", color: "#60A5FA" },
badgeC: { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" },
tagBg: "rgba(212, 175, 55, 0.15)",
tagColor: "#D4AF37",
spinnerColor: "#D4AF37",
},
default: {
bg: "white",
cardBg: "white",
cardHoverBg: "gray.50",
cardBorder: "gray.200",
cardHoverBorder: "blue.300",
textPrimary: "gray.800",
textSecondary: "gray.600",
textMuted: "gray.500",
gold: "blue.500",
goldLight: "blue.400",
inputBg: "white",
inputBorder: "gray.200",
buttonBg: "blue.500",
buttonText: "white",
buttonHoverBg: "blue.600",
badgeS: { bg: "red.100", color: "red.600" },
badgeA: { bg: "orange.100", color: "orange.600" },
badgeB: { bg: "yellow.100", color: "yellow.600" },
badgeC: { bg: "green.100", color: "green.600" },
tagBg: "cyan.50",
tagColor: "cyan.600",
spinnerColor: "blue.500",
},
};
/**
* 新闻动态 Tab 组件
*
@@ -48,6 +100,7 @@ import {
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
* - themePreset: 主题预设 'blackGold' | 'default'
*/
const NewsEventsTab = ({
newsEvents = [],
@@ -65,7 +118,11 @@ const NewsEventsTab = ({
onSearch,
onPageChange,
cardBg,
themePreset = "default",
}) => {
// 获取主题配色
const theme = THEME_PRESETS[themePreset] || THEME_PRESETS.default;
const isBlackGold = themePreset === "blackGold";
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
@@ -80,15 +137,25 @@ const NewsEventsTab = ({
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射
const getImportanceColor = (importance) => {
// 重要性颜色映射 - 根据主题返回不同配色
const getImportanceBadgeStyle = (importance) => {
if (isBlackGold) {
const styles = {
S: theme.badgeS,
A: theme.badgeA,
B: theme.badgeB,
C: theme.badgeC,
};
return styles[importance] || { bg: "rgba(107, 114, 128, 0.2)", color: "#9CA3AF" };
}
// 默认主题使用 colorScheme
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return colorMap[importance] || "gray";
return { colorScheme: colorMap[importance] || "gray" };
};
// 处理搜索输入
@@ -129,19 +196,26 @@ const NewsEventsTab = ({
// 如果开始页大于1显示省略号
if (startPage > 1) {
pageButtons.push(
<Text key="start-ellipsis" fontSize="sm" color="gray.400">
<Text key="start-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
const isActive = i === currentPage;
pageButtons.push(
<Button
key={i}
size="sm"
variant={i === currentPage ? "solid" : "outline"}
colorScheme={i === currentPage ? "blue" : "gray"}
bg={isActive ? theme.buttonBg : (isBlackGold ? theme.inputBg : undefined)}
color={isActive ? theme.buttonText : theme.textSecondary}
borderColor={isActive ? theme.gold : theme.cardBorder}
borderWidth="1px"
_hover={{
bg: isActive ? theme.buttonHoverBg : theme.cardHoverBg,
borderColor: theme.gold
}}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
@@ -153,7 +227,7 @@ const NewsEventsTab = ({
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
<Text key="end-ellipsis" fontSize="sm" color={theme.textMuted}>
...
</Text>
);
@@ -164,7 +238,7 @@ const NewsEventsTab = ({
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} shadow="md">
<Card bg={cardBg || theme.cardBg} shadow="md" borderColor={theme.cardBorder} borderWidth={isBlackGold ? "1px" : "0"}>
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
@@ -172,17 +246,25 @@ const NewsEventsTab = ({
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
<SearchIcon color={theme.textMuted} />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
bg={theme.inputBg}
borderColor={theme.inputBorder}
color={theme.textPrimary}
_placeholder={{ color: theme.textMuted }}
_hover={{ borderColor: theme.gold }}
_focus={{ borderColor: theme.gold, boxShadow: `0 0 0 1px ${theme.gold}` }}
/>
</InputGroup>
<Button
colorScheme="blue"
bg={theme.buttonBg}
color={theme.buttonText}
_hover={{ bg: theme.buttonHoverBg }}
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
@@ -193,10 +275,10 @@ const NewsEventsTab = ({
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.500" />
<Text fontSize="sm" color="gray.600">
<Icon as={FaNewspaper} color={theme.gold} />
<Text fontSize="sm" color={theme.textSecondary}>
共找到{" "}
<Text as="span" fontWeight="bold" color="blue.600">
<Text as="span" fontWeight="bold" color={theme.gold}>
{newsPagination.total}
</Text>{" "}
条新闻
@@ -211,15 +293,15 @@ const NewsEventsTab = ({
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.600">正在加载新闻...</Text>
<Spinner size="xl" color={theme.spinnerColor} thickness="4px" />
<Text color={theme.textSecondary}>正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceColor = getImportanceColor(
const importanceBadgeStyle = getImportanceBadgeStyle(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
@@ -228,10 +310,12 @@ const NewsEventsTab = ({
<Card
key={event.id || idx}
variant="outline"
bg={theme.cardBg}
borderColor={theme.cardBorder}
_hover={{
bg: "gray.50",
bg: theme.cardHoverBg,
shadow: "md",
borderColor: "blue.300",
borderColor: theme.cardHoverBorder,
}}
transition="all 0.2s"
>
@@ -243,13 +327,14 @@ const NewsEventsTab = ({
<HStack>
<Icon
as={eventTypeIcon}
color="blue.500"
color={theme.gold}
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
color={theme.textPrimary}
>
{event.title}
</Text>
@@ -259,22 +344,29 @@ const NewsEventsTab = ({
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
colorScheme={importanceColor}
variant="solid"
{...(isBlackGold ? {} : { colorScheme: importanceBadgeStyle.colorScheme, variant: "solid" })}
bg={isBlackGold ? importanceBadgeStyle.bg : undefined}
color={isBlackGold ? importanceBadgeStyle.color : undefined}
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge colorScheme="blue" variant="outline">
<Badge
{...(isBlackGold ? {} : { colorScheme: "blue", variant: "outline" })}
bg={isBlackGold ? "rgba(59, 130, 246, 0.2)" : undefined}
color={isBlackGold ? "#60A5FA" : undefined}
borderColor={isBlackGold ? "rgba(59, 130, 246, 0.3)" : undefined}
>
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
colorScheme="purple"
variant="subtle"
{...(isBlackGold ? {} : { colorScheme: "purple", variant: "subtle" })}
bg={isBlackGold ? "rgba(139, 92, 246, 0.2)" : undefined}
color={isBlackGold ? "#A78BFA" : undefined}
>
投资分: {event.invest_score}
</Badge>
@@ -287,8 +379,9 @@ const NewsEventsTab = ({
<Tag
key={kidx}
size="sm"
colorScheme="cyan"
variant="subtle"
{...(isBlackGold ? {} : { colorScheme: "cyan", variant: "subtle" })}
bg={isBlackGold ? theme.tagBg : undefined}
color={isBlackGold ? theme.tagColor : undefined}
>
{typeof keyword === "string"
? keyword
@@ -304,7 +397,7 @@ const NewsEventsTab = ({
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.created_at
? new Date(
event.created_at
@@ -321,9 +414,9 @@ const NewsEventsTab = ({
<Icon
as={FaEye}
boxSize={3}
color="gray.400"
color={theme.textMuted}
/>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.view_count}
</Text>
</HStack>
@@ -333,16 +426,16 @@ const NewsEventsTab = ({
<Icon
as={FaFire}
boxSize={3}
color="orange.400"
color={theme.goldLight}
/>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color="gray.400">
<Text fontSize="xs" color={theme.textMuted}>
@{event.creator.username}
</Text>
)}
@@ -353,7 +446,7 @@ const NewsEventsTab = ({
{event.description && (
<Text
fontSize="sm"
color="gray.700"
color={theme.textSecondary}
lineHeight="1.6"
>
{event.description}
@@ -367,18 +460,18 @@ const NewsEventsTab = ({
<Box
pt={2}
borderTop="1px"
borderColor="gray.200"
borderColor={theme.cardBorder}
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color="gray.500"
color={theme.textMuted}
/>
<Text
fontSize="xs"
color="gray.500"
color={theme.textMuted}
fontWeight="medium"
>
相关涨跌:
@@ -387,7 +480,7 @@ const NewsEventsTab = ({
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
平均
</Text>
<Text
@@ -395,8 +488,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_avg_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_avg_chg > 0 ? "+" : ""}
@@ -407,7 +500,7 @@ const NewsEventsTab = ({
{event.related_max_chg !== null &&
event.related_max_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
最大
</Text>
<Text
@@ -415,8 +508,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_max_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_max_chg > 0 ? "+" : ""}
@@ -427,7 +520,7 @@ const NewsEventsTab = ({
{event.related_week_chg !== null &&
event.related_week_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={theme.textMuted}>
</Text>
<Text
@@ -435,8 +528,8 @@ const NewsEventsTab = ({
fontWeight="bold"
color={
event.related_week_chg > 0
? "red.500"
: "green.500"
? "#EF4444"
: "#10B981"
}
>
{event.related_week_chg > 0
@@ -465,7 +558,7 @@ const NewsEventsTab = ({
flexWrap="wrap"
>
{/* 分页信息 */}
<Text fontSize="sm" color="gray.600">
<Text fontSize="sm" color={theme.textSecondary}>
{newsPagination.page} / {newsPagination.pages}
</Text>
@@ -473,6 +566,11 @@ const NewsEventsTab = ({
<HStack spacing={2}>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
@@ -481,6 +579,11 @@ const NewsEventsTab = ({
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
@@ -494,6 +597,11 @@ const NewsEventsTab = ({
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
@@ -503,6 +611,11 @@ const NewsEventsTab = ({
</Button>
<Button
size="sm"
bg={isBlackGold ? theme.inputBg : undefined}
color={theme.textSecondary}
borderColor={theme.cardBorder}
borderWidth="1px"
_hover={{ bg: theme.cardHoverBg, borderColor: theme.gold }}
onClick={() => handlePageChange(newsPagination.pages)}
isDisabled={!newsPagination.has_next || newsLoading}
rightIcon={<Icon as={FaChevronRight} />}
@@ -517,11 +630,11 @@ const NewsEventsTab = ({
) : (
<Center h="400px">
<VStack spacing={3}>
<Icon as={FaNewspaper} boxSize={16} color="gray.300" />
<Text color="gray.500" fontSize="lg" fontWeight="medium">
<Icon as={FaNewspaper} boxSize={16} color={isBlackGold ? theme.gold : "gray.300"} opacity={0.5} />
<Text color={theme.textSecondary} fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color="gray.400">
<Text fontSize="sm" color={theme.textMuted}>
{searchQuery ? "尝试修改搜索关键词" : "该公司暂无新闻动态"}
</Text>
</VStack>

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { Announcement } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -28,34 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
);
const result = (await response.json()) as ApiResponse<Announcement[]>;
const loadData = async () => {
setLoading(true);
setError(null);
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
try {
const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
`/api/stock/${stockCode}/announcements?limit=20`,
{ signal: controller.signal }
);
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
} catch (err) {
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return { announcements, loading, error };
};

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { BasicInfo } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -28,32 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
const result = (await response.json()) as ApiResponse<BasicInfo>;
const loadData = async () => {
setLoading(true);
setError(null);
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
try {
const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
`/api/stock/${stockCode}/basic-info`,
{ signal: controller.signal }
);
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
} catch (err) {
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return { basicInfo, loading, error };
};

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { Branch } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -28,32 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
const result = (await response.json()) as ApiResponse<Branch[]>;
const loadData = async () => {
setLoading(true);
setError(null);
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
try {
const { data: result } = await axios.get<ApiResponse<Branch[]>>(
`/api/stock/${stockCode}/branches`,
{ signal: controller.signal }
);
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
} catch (err) {
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return { branches, loading, error };
};

View File

@@ -1,140 +0,0 @@
// src/views/Company/components/CompanyOverview/hooks/useCompanyOverviewData.ts
// 公司概览数据加载 Hook
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type {
BasicInfo,
ActualControl,
Concentration,
Management,
Shareholder,
Branch,
Announcement,
DisclosureSchedule,
CompanyOverviewData,
} from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
}
/**
* 公司概览数据加载 Hook
* @param propStockCode - 股票代码
* @returns 公司概览数据
*/
export const useCompanyOverviewData = (propStockCode?: string): CompanyOverviewData => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(false);
// 基本信息数据
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]);
const [management, setManagement] = useState<Management[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
}
}, [propStockCode, stockCode]);
// 加载基本信息数据9个接口
const loadBasicInfoData = useCallback(async () => {
if (dataLoaded) return;
setLoading(true);
try {
const [
basicRes,
actualRes,
concentrationRes,
managementRes,
circulationRes,
shareholdersRes,
branchesRes,
announcementsRes,
disclosureRes,
] = await Promise.all([
fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`).then((r) =>
r.json()
) as Promise<ApiResponse<BasicInfo>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
r.json()
) as Promise<ApiResponse<ActualControl[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
r.json()
) as Promise<ApiResponse<Concentration[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`).then((r) =>
r.json()
) as Promise<ApiResponse<Management[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`).then((r) =>
r.json()
) as Promise<ApiResponse<Branch[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`).then((r) =>
r.json()
) as Promise<ApiResponse<Announcement[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`).then((r) =>
r.json()
) as Promise<ApiResponse<DisclosureSchedule[]>>,
]);
if (basicRes.success) setBasicInfo(basicRes.data);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (managementRes.success) setManagement(managementRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (branchesRes.success) setBranches(branchesRes.data);
if (announcementsRes.success) setAnnouncements(announcementsRes.data);
if (disclosureRes.success) setDisclosureSchedule(disclosureRes.data);
setDataLoaded(true);
} catch (err) {
logger.error("useCompanyOverviewData", "loadBasicInfoData", err, { stockCode });
} finally {
setLoading(false);
}
}, [stockCode, dataLoaded]);
// 首次加载
useEffect(() => {
if (stockCode) {
loadBasicInfoData();
}
}, [stockCode, loadBasicInfoData]);
return {
basicInfo,
actualControl,
concentration,
management,
topCirculationShareholders,
topShareholders,
branches,
announcements,
disclosureSchedule,
loading,
dataLoaded,
};
};

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -28,34 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
);
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
const loadData = async () => {
setLoading(true);
setError(null);
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
try {
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
`/api/stock/${stockCode}/disclosure-schedule`,
{ signal: controller.signal }
);
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
} catch (err) {
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return { disclosureSchedule, loading, error };
};

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { Management } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -28,34 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
);
const result = (await response.json()) as ApiResponse<Management[]>;
const loadData = async () => {
setLoading(true);
setError(null);
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
try {
const { data: result } = await axios.get<ApiResponse<Management[]>>(
`/api/stock/${stockCode}/management?active_only=true`,
{ signal: controller.signal }
);
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
} catch (err) {
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return { management, loading, error };
};

View File

@@ -1,13 +1,11 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
import type { ActualControl, Concentration, Shareholder } from "../types";
const API_BASE_URL = getApiBase();
interface ApiResponse<T> {
success: boolean;
data: T;
@@ -34,43 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => {
useEffect(() => {
if (!stockCode) return;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
r.json()
) as Promise<ApiResponse<ActualControl[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
r.json()
) as Promise<ApiResponse<Concentration[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
]);
const loadData = async () => {
setLoading(true);
setError(null);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err) {
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
}, [stockCode]);
try {
const [
{ data: actualRes },
{ data: concentrationRes },
{ data: shareholdersRes },
{ data: circulationRes },
] = await Promise.all([
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
]);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [loadData]);
return () => controller.abort();
}, [stockCode]);
return {
actualControl,

View File

@@ -4,10 +4,9 @@
import React from "react";
import { VStack } from "@chakra-ui/react";
import { useBasicInfo } from "./hooks/useBasicInfo";
import type { CompanyOverviewProps } from "./types";
// 子组件(暂保持 JS
// 子组件
import BasicInfoTab from "./BasicInfoTab";
/**
@@ -18,17 +17,13 @@ import BasicInfoTab from "./BasicInfoTab";
*
* 懒加载策略:
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo
*/
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
const { basicInfo } = useBasicInfo(stockCode);
return (
<VStack spacing={6} align="stretch">
{/* 基本信息内容 - 传入 stockCode内部懒加载各 Tab 数据 */}
<BasicInfoTab
stockCode={stockCode}
basicInfo={basicInfo}
/>
<BasicInfoTab stockCode={stockCode} />
</VStack>
);
};

View File

@@ -22,6 +22,15 @@ export interface BasicInfo {
email?: string;
tel?: string;
company_intro?: string;
// 工商信息字段
credit_code?: string;
company_size?: string;
reg_address?: string;
office_address?: string;
accounting_firm?: string;
law_firm?: string;
main_business?: string;
business_scope?: string;
}
/**
@@ -107,23 +116,6 @@ export interface DisclosureSchedule {
disclosure_date?: string;
}
/**
* useCompanyOverviewData Hook 返回值
*/
export interface CompanyOverviewData {
basicInfo: BasicInfo | null;
actualControl: ActualControl[];
concentration: Concentration[];
management: Management[];
topCirculationShareholders: Shareholder[];
topShareholders: Shareholder[];
branches: Branch[];
announcements: Announcement[];
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
dataLoaded: boolean;
}
/**
* CompanyOverview 组件 Props
*/

View File

@@ -3,13 +3,11 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import axios from "@utils/axiosConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
const API_BASE_URL = getApiBase();
/**
* Tab 与 API 接口映射
* - strategy 和 business 共用 comprehensive 接口
@@ -40,17 +38,20 @@ const DeepAnalysis = ({ stockCode }) => {
const [comprehensiveData, setComprehensiveData] = useState(null);
const [valueChainData, setValueChainData] = useState(null);
const [keyFactorsData, setKeyFactorsData] = useState(null);
const [industryRankData, setIndustryRankData] = useState(null);
// 各接口独立的 loading 状态
const [comprehensiveLoading, setComprehensiveLoading] = useState(false);
const [valueChainLoading, setValueChainLoading] = useState(false);
const [keyFactorsLoading, setKeyFactorsLoading] = useState(false);
const [industryRankLoading, setIndustryRankLoading] = useState(false);
// 已加载的接口记录(用于缓存判断)
const loadedApisRef = useRef({
comprehensive: false,
valueChain: false,
keyFactors: false,
industryRank: false,
});
// 业务板块展开状态
@@ -81,9 +82,9 @@ const DeepAnalysis = ({ stockCode }) => {
switch (apiKey) {
case "comprehensive":
setComprehensiveLoading(true);
const comprehensiveRes = await fetch(
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
).then((r) => r.json());
const { data: comprehensiveRes } = await axios.get(
`/api/company/comprehensive-analysis/${stockCode}`
);
// 检查 stockCode 是否已变更(防止竞态)
if (currentStockCodeRef.current === stockCode) {
if (comprehensiveRes.success)
@@ -94,9 +95,9 @@ const DeepAnalysis = ({ stockCode }) => {
case "valueChain":
setValueChainLoading(true);
const valueChainRes = await fetch(
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
).then((r) => r.json());
const { data: valueChainRes } = await axios.get(
`/api/company/value-chain-analysis/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (valueChainRes.success) setValueChainData(valueChainRes.data);
loadedApisRef.current.valueChain = true;
@@ -105,15 +106,26 @@ const DeepAnalysis = ({ stockCode }) => {
case "keyFactors":
setKeyFactorsLoading(true);
const keyFactorsRes = await fetch(
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
).then((r) => r.json());
const { data: keyFactorsRes } = await axios.get(
`/api/company/key-factors-timeline/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
loadedApisRef.current.keyFactors = true;
}
break;
case "industryRank":
setIndustryRankLoading(true);
const { data: industryRankRes } = await axios.get(
`/api/financial/industry-rank/${stockCode}`
);
if (currentStockCodeRef.current === stockCode) {
if (industryRankRes.success) setIndustryRankData(industryRankRes.data);
loadedApisRef.current.industryRank = true;
}
break;
default:
break;
}
@@ -126,6 +138,7 @@ const DeepAnalysis = ({ stockCode }) => {
if (apiKey === "comprehensive") setComprehensiveLoading(false);
if (apiKey === "valueChain") setValueChainLoading(false);
if (apiKey === "keyFactors") setKeyFactorsLoading(false);
if (apiKey === "industryRank") setIndustryRankLoading(false);
}
},
[stockCode]
@@ -165,17 +178,20 @@ const DeepAnalysis = ({ stockCode }) => {
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setIndustryRankData(null);
setExpandedSegments({});
loadedApisRef.current = {
comprehensive: false,
valueChain: false,
keyFactors: false,
industryRank: false,
};
// 重置为默认 Tab 并加载数据
setActiveTab("strategy");
// 加载默认 Tab 的数据
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank
loadApiData("comprehensive");
loadApiData("industryRank");
}
}, [stockCode, loadApiData]);
@@ -199,6 +215,7 @@ const DeepAnalysis = ({ stockCode }) => {
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
industryRankData={industryRankData}
loading={getCurrentLoading()}
cardBg="white"
expandedSegments={expandedSegments}

View File

@@ -0,0 +1,156 @@
// src/views/Company/components/DynamicTracking/components/ForecastPanel.js
// 业绩预告面板 - 黑金主题
import React, { useState, useEffect, useCallback } from 'react';
import {
VStack,
Box,
Flex,
Text,
Spinner,
Center,
} from '@chakra-ui/react';
import { Tag } from 'antd';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
// 黑金主题
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.15)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
cardBg: 'rgba(26, 32, 44, 0.6)',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#48BB78',
};
// 预告类型配色
const getForecastTypeStyle = (type) => {
const styles = {
'预增': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'预减': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'扭亏': { color: '#D4AF37', bg: 'rgba(212, 175, 55, 0.15)', border: 'rgba(212, 175, 55, 0.3)' },
'首亏': { color: '#48BB78', bg: 'rgba(72, 187, 120, 0.15)', border: 'rgba(72, 187, 120, 0.3)' },
'续亏': { color: '#718096', bg: 'rgba(113, 128, 150, 0.15)', border: 'rgba(113, 128, 150, 0.3)' },
'续盈': { color: '#E53E3E', bg: 'rgba(229, 62, 62, 0.15)', border: 'rgba(229, 62, 62, 0.3)' },
'略增': { color: '#ED8936', bg: 'rgba(237, 137, 54, 0.15)', border: 'rgba(237, 137, 54, 0.3)' },
'略减': { color: '#38B2AC', bg: 'rgba(56, 178, 172, 0.15)', border: 'rgba(56, 178, 172, 0.3)' },
};
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
};
const ForecastPanel = ({ stockCode }) => {
const [forecast, setForecast] = useState(null);
const [loading, setLoading] = useState(false);
const loadForecast = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
try {
const { data: result } = await axios.get(
`/api/stock/${stockCode}/forecast`
);
if (result.success && result.data) {
setForecast(result.data);
}
} catch (err) {
logger.error('ForecastPanel', 'loadForecast', err, { stockCode });
setForecast(null);
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadForecast();
}, [loadForecast]);
if (loading) {
return (
<Center py={10}>
<Spinner size="lg" color={THEME.gold} />
</Center>
);
}
if (!forecast?.forecasts?.length) {
return (
<Center py={10}>
<Text color={THEME.textSecondary}>暂无业绩预告数据</Text>
</Center>
);
}
return (
<VStack spacing={3} align="stretch">
{forecast.forecasts.map((item, idx) => {
const typeStyle = getForecastTypeStyle(item.forecast_type);
return (
<Box
key={idx}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
{/* 头部:类型标签 + 报告期 */}
<Flex justify="space-between" align="center" mb={3}>
<Tag
style={{
color: typeStyle.color,
background: typeStyle.bg,
border: `1px solid ${typeStyle.border}`,
fontWeight: 500,
}}
>
{item.forecast_type}
</Tag>
<Text fontSize="sm" color={THEME.textSecondary}>
报告期: {item.report_date}
</Text>
</Flex>
{/* 内容 */}
<Text color={THEME.text} fontSize="sm" lineHeight="1.6" mb={3}>
{item.content}
</Text>
{/* 原因(如有) */}
{item.reason && (
<Text fontSize="xs" color={THEME.textSecondary} mb={3}>
{item.reason}
</Text>
)}
{/* 变动范围 */}
{item.change_range?.lower !== undefined && (
<Flex align="center" gap={2}>
<Text fontSize="sm" color={THEME.textSecondary}>
预计变动范围:
</Text>
<Tag
style={{
color: THEME.gold,
background: THEME.goldLight,
border: `1px solid ${THEME.goldBorder}`,
fontWeight: 600,
}}
>
{item.change_range.lower}% ~ {item.change_range.upper}%
</Tag>
</Flex>
)}
</Box>
);
})}
</VStack>
);
};
export default ForecastPanel;

View File

@@ -0,0 +1,111 @@
// src/views/Company/components/DynamicTracking/components/NewsPanel.js
// 新闻动态面板(包装 NewsEventsTab
import React, { useState, useEffect, useCallback } from 'react';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
import NewsEventsTab from '../../CompanyOverview/NewsEventsTab';
const NewsPanel = ({ stockCode }) => {
const [newsEvents, setNewsEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
});
const [searchQuery, setSearchQuery] = useState('');
const [stockName, setStockName] = useState('');
// 获取股票名称
const fetchStockName = useCallback(async () => {
try {
const { data: result } = await axios.get(
`/api/stock/${stockCode}/basic-info`
);
if (result.success && result.data) {
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
setStockName(name);
return name;
}
return stockCode;
} catch (err) {
logger.error('NewsPanel', 'fetchStockName', err, { stockCode });
return stockCode;
}
}, [stockCode]);
// 加载新闻事件
const loadNewsEvents = useCallback(
async (query, page = 1) => {
setLoading(true);
try {
const searchTerm = query || stockName || stockCode;
const { data: result } = await axios.get(
`/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
);
if (result.success) {
setNewsEvents(result.data || []);
setPagination({
page: result.pagination?.page || page,
per_page: result.pagination?.per_page || 10,
total: result.pagination?.total || 0,
pages: result.pagination?.pages || 0,
has_next: result.pagination?.has_next || false,
has_prev: result.pagination?.has_prev || false,
});
}
} catch (err) {
logger.error('NewsPanel', 'loadNewsEvents', err, { stockCode });
setNewsEvents([]);
} finally {
setLoading(false);
}
},
[stockCode, stockName]
);
// 首次加载
useEffect(() => {
const initLoad = async () => {
if (stockCode) {
const name = await fetchStockName();
await loadNewsEvents(name, 1);
}
};
initLoad();
}, [stockCode, fetchStockName, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {
setSearchQuery(value);
};
const handleSearch = () => {
loadNewsEvents(searchQuery || stockName, 1);
};
// 分页处理
const handlePageChange = (page) => {
loadNewsEvents(searchQuery || stockName, page);
};
return (
<NewsEventsTab
newsEvents={newsEvents}
newsLoading={loading}
newsPagination={pagination}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
themePreset="blackGold"
/>
);
};
export default NewsPanel;

View File

@@ -0,0 +1,4 @@
// src/views/Company/components/DynamicTracking/components/index.js
export { default as NewsPanel } from './NewsPanel';
export { default as ForecastPanel } from './ForecastPanel';

View File

@@ -1,204 +1,65 @@
// src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
} from "@chakra-ui/react";
import { FaNewspaper, FaBullhorn, FaCalendarAlt } from "react-icons/fa";
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@chakra-ui/react';
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import NewsEventsTab from "../CompanyOverview/NewsEventsTab";
import AnnouncementsPanel from "../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel";
import DisclosureSchedulePanel from "../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel";
import { THEME } from "../CompanyOverview/BasicInfoTab/config";
// API配置
const API_BASE_URL = getApiBase();
import SubTabContainer from '@components/SubTabContainer';
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
import { NewsPanel, ForecastPanel } from './components';
// 二级 Tab 配置
const TRACKING_TABS = [
{ key: "news", name: "新闻动态", icon: FaNewspaper },
{ key: "announcements", name: "公司公告", icon: FaBullhorn },
{ key: "disclosure", name: "财报披露日程", icon: FaCalendarAlt },
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
];
/**
* 动态跟踪组件
*
* 功能:
* - 二级 Tab 结构
* - Tab1: 新闻动态(复用 NewsEventsTab
* - 预留后续扩展
* - 使用 SubTabContainer 实现二级导航
* - Tab1: 新闻动态
* - Tab2: 公司公告
* - Tab3: 财报披露日程
* - Tab4: 业绩预告
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DynamicTracking = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || "000001");
const [stockCode, setStockCode] = useState(propStockCode || '000001');
const [activeTab, setActiveTab] = useState(0);
// 新闻动态状态
const [newsEvents, setNewsEvents] = useState([]);
const [newsLoading, setNewsLoading] = useState(false);
const [newsPagination, setNewsPagination] = useState({
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_next: false,
has_prev: false,
});
const [searchQuery, setSearchQuery] = useState("");
const [stockName, setStockName] = useState("");
const [dataLoaded, setDataLoaded] = useState(false);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
setDataLoaded(false);
setNewsEvents([]);
setStockName("");
setSearchQuery("");
}
}, [propStockCode, stockCode]);
// 获取股票名称(用于搜索)
const fetchStockName = useCallback(async () => {
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/basic-info`
);
const result = await response.json();
if (result.success && result.data) {
const name = result.data.SECNAME || result.data.ORGNAME || stockCode;
setStockName(name);
return name;
}
return stockCode;
} catch (err) {
logger.error("DynamicTracking", "fetchStockName", err, { stockCode });
return stockCode;
}
}, [stockCode]);
// 加载新闻事件数据
const loadNewsEvents = useCallback(
async (query, page = 1) => {
setNewsLoading(true);
try {
const searchTerm = query || stockName || stockCode;
const response = await fetch(
`${API_BASE_URL}/api/events?q=${encodeURIComponent(searchTerm)}&page=${page}&per_page=10`
);
const result = await response.json();
if (result.success) {
setNewsEvents(result.data || []);
setNewsPagination({
page: result.pagination?.page || page,
per_page: result.pagination?.per_page || 10,
total: result.pagination?.total || 0,
pages: result.pagination?.pages || 0,
has_next: result.pagination?.has_next || false,
has_prev: result.pagination?.has_prev || false,
});
}
} catch (err) {
logger.error("DynamicTracking", "loadNewsEvents", err, { stockCode });
setNewsEvents([]);
} finally {
setNewsLoading(false);
}
},
[stockCode, stockName]
// 传递给子组件的 props
const componentProps = useMemo(
() => ({
stockCode,
}),
[stockCode]
);
// 首次加载
useEffect(() => {
const initLoad = async () => {
if (stockCode && !dataLoaded) {
const name = await fetchStockName();
await loadNewsEvents(name, 1);
setDataLoaded(true);
}
};
initLoad();
}, [stockCode, dataLoaded, fetchStockName, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {
setSearchQuery(value);
};
const handleSearch = () => {
loadNewsEvents(searchQuery || stockName, 1);
};
// 分页处理
const handlePageChange = (page) => {
loadNewsEvents(searchQuery || stockName, page);
};
return (
<Box bg={THEME.bg} p={4} borderRadius="md">
<Tabs
variant="soft-rounded"
<Box>
<SubTabContainer
tabs={TRACKING_TABS}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onChange={setActiveTab}
onTabChange={(index) => setActiveTab(index)}
isLazy
>
<TabList bg={THEME.cardBg} borderBottom="1px solid" borderColor={THEME.border}>
{TRACKING_TABS.map((tab) => (
<Tab
key={tab.key}
fontWeight="medium"
color={THEME.textSecondary}
_selected={{
color: THEME.tabSelected.color,
bg: THEME.tabSelected.bg,
borderRadius: "md",
}}
_hover={{ color: THEME.gold }}
>
{tab.name}
</Tab>
))}
</TabList>
<TabPanels>
{/* 新闻动态 Tab */}
<TabPanel p={4}>
<NewsEventsTab
newsEvents={newsEvents}
newsLoading={newsLoading}
newsPagination={newsPagination}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
onPageChange={handlePageChange}
cardBg="white"
/>
</TabPanel>
{/* 公司公告 Tab */}
<TabPanel p={4}>
<AnnouncementsPanel stockCode={stockCode} />
</TabPanel>
{/* 财报披露日程 Tab */}
<TabPanel p={4}>
<DisclosureSchedulePanel stockCode={stockCode} />
</TabPanel>
</TabPanels>
</Tabs>
/>
</Box>
);
};

View File

@@ -1,27 +1,12 @@
/**
* 资产负债表组件
* 资产负债表组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Box,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import {
CURRENT_ASSETS_METRICS,
@@ -33,221 +18,308 @@ import {
EQUITY_METRICS,
} from '../constants';
import { getValueByPath } from '../utils';
import type { BalanceSheetTableProps, MetricSectionConfig } from '../types';
import type { BalanceSheetTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.balance-sheet-table .ant-table {
background: transparent !important;
}
.balance-sheet-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.balance-sheet-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.balance-sheet-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.balance-sheet-table .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.balance-sheet-table .positive-change {
color: #E53E3E;
}
.balance-sheet-table .negative-change {
color: #48BB78;
}
.balance-sheet-table .ant-table-placeholder {
background: transparent !important;
}
.balance-sheet-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
currentAssets: true,
nonCurrentAssets: true,
currentLiabilities: true,
nonCurrentLiabilities: true,
equity: true,
});
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
// 资产部分配置
const assetSections: MetricSectionConfig[] = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
];
// 负债部分配置
const liabilitySections: MetricSectionConfig[] = [
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
];
// 权益部分配置
const equitySections: MetricSectionConfig[] = [EQUITY_METRICS];
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const renderSection = (sections: MetricSectionConfig[]) => (
<>
{sections.map((section) => (
<React.Fragment key={section.key}>
{section.title !== '资产总计' &&
section.title !== '负债合计' && (
<Tr
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? (
<ChevronUpIcon />
) : (
<ChevronDownIcon />
)}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
)}
{(expandedSections[section.key] ||
section.title === '资产总计' ||
section.title === '负债合计' ||
section.title === '股东权益合计') &&
section.metrics.map((metric) => {
const rowData = data.map((item) =>
getValueByPath<number>(item, metric.path)
);
// 所有分类配置
const allSections = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() =>
showMetricChart(metric.name, metric.key, data, metric.path)
}
bg={metric.isTotal ? 'blue.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal && <Box w={4} />}
<Text
fontWeight={metric.isTotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
>
{metric.name}
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
: {formatUtils.formatLargeNumber(value)}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
fontWeight={metric.isTotal ? 'bold' : 'normal'}
>
{formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看图表"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, data, metric.path);
}}
/>
</Td>
</Tr>
);
})}
</React.Fragment>
))}
</>
);
allSections.forEach((section) => {
// 添加分组标题行(汇总行不显示标题)
if (!['资产总计', '负债合计'].includes(section.title)) {
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || ['资产总计', '负债合计'].includes(section.title),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{renderSection(assetSections)}
<Tr height={2} />
{renderSection(liabilitySections)}
<Tr height={2} />
{renderSection(equitySections)}
</Tbody>
</Table>
</TableContainer>
<Box className="balance-sheet-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};

View File

@@ -1,156 +1,268 @@
/**
* 现金流量表组件
* 现金流量表组件 - Ant Design 黑金主题
*/
import React from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { CASHFLOW_METRICS } from '../constants';
import { getValueByPath } from '../utils';
import type { CashflowTableProps } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.cashflow-table .ant-table {
background: transparent !important;
}
.cashflow-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.cashflow-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.cashflow-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.cashflow-table .ant-table-cell-fix-left,
.cashflow-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.cashflow-table .positive-value {
color: #E53E3E;
}
.cashflow-table .negative-value {
color: #48BB78;
}
.cashflow-table .positive-change {
color: #E53E3E;
}
.cashflow-table .negative-change {
color: #48BB78;
}
.cashflow-table .ant-table-placeholder {
background: transparent !important;
}
.cashflow-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 核心指标
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const CashflowTable: React.FC<CashflowTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 8);
const displayData = data.slice(0, maxColumns);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1}>
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
// 构建表格数据
const tableData = useMemo(() => {
return CASHFLOW_METRICS.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: CORE_METRICS.includes(metric.key),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 1);
const isNegative = value !== undefined && value < 0;
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text className={isNegative ? 'negative-value' : 'positive-value'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 50 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
</VStack>
</Th>
))}
<Th></Th>
</Tr>
</Thead>
<Tbody>
{CASHFLOW_METRICS.map((metric) => {
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
),
},
];
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack>
<Text fontWeight="medium">{metric.name}</Text>
{['operating_net', 'free_cash_flow'].includes(metric.key) && (
<Badge colorScheme="purple"></Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const isNegative = value !== undefined && value < 0;
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
return cols;
}, [displayData, data, showMetricChart]);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={isNegative ? negativeColor : positiveColor}
>
{formatUtils.formatLargeNumber(value, 1)}
</Text>
</Tooltip>
{Math.abs(change) > 50 && (
<Text
position="absolute"
top="0"
right="1"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看趋势"
/>
</Td>
</Tr>
);
return (
<Box className="cashflow-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
</Tbody>
</Table>
</TableContainer>
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};

View File

@@ -1,14 +1,20 @@
/**
* 综合对比分析组件
* 综合对比分析组件 - 黑金主题
*/
import React from 'react';
import { Card, CardBody } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getComparisonChartOption } from '../utils';
import type { ComparisonAnalysisProps } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
};
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparison }) => {
if (!Array.isArray(comparison) || comparison.length === 0) return null;
@@ -29,11 +35,15 @@ export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparis
const chartOption = getComparisonChartOption(revenueData, profitData);
return (
<Card>
<CardBody>
<ReactECharts option={chartOption} style={{ height: '400px' }} />
</CardBody>
</Card>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={chartOption} style={{ height: '350px' }} />
</Box>
);
};

View File

@@ -1,33 +1,12 @@
/**
* 财务指标表格组件
* 财务指标表格组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
VStack,
HStack,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Heading,
SimpleGrid,
Box,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import React, { useState, useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, SimpleGrid, Card, CardBody, CardHeader, Heading, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
@@ -35,25 +14,96 @@ import type { FinancialMetricsTableProps } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.financial-metrics-table .ant-table {
background: transparent !important;
}
.financial-metrics-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.financial-metrics-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-metrics-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-metrics-table .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-metrics-table .positive-change {
color: #E53E3E;
}
.financial-metrics-table .negative-change {
color: #48BB78;
}
.financial-metrics-table .positive-value {
color: #E53E3E;
}
.financial-metrics-table .negative-value {
color: #48BB78;
}
.financial-metrics-table .ant-table-placeholder {
background: transparent !important;
}
.financial-metrics-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
@@ -61,172 +111,202 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
const displayData = data.slice(0, maxColumns);
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
// 构建表格数据
const tableData = useMemo(() => {
return currentCategory.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData, currentCategory]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: currentCategory.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = selectedCategory === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, data, showMetricChart, currentCategory, selectedCategory]);
return (
<VStack spacing={4} align="stretch">
<Box>
{/* 分类选择器 */}
<HStack spacing={2} wrap="wrap">
<HStack spacing={2} mb={4} flexWrap="wrap">
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map(
([key, category]) => (
<Button
key={key}
size="sm"
variant={selectedCategory === key ? 'solid' : 'outline'}
colorScheme="blue"
bg={selectedCategory === key ? 'rgba(212, 175, 55, 0.3)' : 'transparent'}
color={selectedCategory === key ? '#D4AF37' : 'gray.400'}
borderColor="rgba(212, 175, 55, 0.3)"
_hover={{
bg: 'rgba(212, 175, 55, 0.2)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
onClick={() => setSelectedCategory(key)}
>
{category.title}
{category.title.replace('指标', '')}
</Button>
)
)}
</HStack>
{/* 指标表格 */}
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
{currentCategory.title}
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="100px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{currentCategory.metrics.map((metric) => {
const rowData = data.map((item) =>
getValueByPath<number>(item, metric.path)
);
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() =>
showMetricChart(metric.name, metric.key, data, metric.path)
}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">
{metric.name}
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 判断指标性质
const isNegative = isNegativeIndicator(metric.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const displayColor = isNegative
? change > 0
? negativeColor
: positiveColor
: change > 0
? positiveColor
: negativeColor;
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity * 0.3)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
{metric.name}: {value?.toFixed(2) || '-'}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={
selectedCategory === 'growth'
? value !== undefined && value > 0
? positiveColor
: value !== undefined && value < 0
? negativeColor
: 'gray.500'
: 'inherit'
}
>
{value?.toFixed(2) || '-'}
</Text>
</Tooltip>
{Math.abs(change) > 20 &&
value !== undefined &&
Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看趋势"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, data, metric.path);
}}
/>
</Td>
</Tr>
);
<Box className="financial-metrics-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
</Tbody>
</Table>
</TableContainer>
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
{/* 关键指标快速对比 */}
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{data[0] &&
[
{data[0] && (
<Card mt={4} bg="transparent" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardHeader py={3} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Heading size="sm" color="#D4AF37"></Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{[
{
label: 'ROE',
value: getValueByPath<number>(data[0], 'profitability.roe'),
@@ -258,21 +338,22 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
format: 'percent',
},
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
<Text fontSize="xs" color="gray.500">
<Box key={idx} p={3} borderRadius="md" bg="rgba(212, 175, 55, 0.1)" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Text fontSize="xs" color="gray.400">
{item.label}
</Text>
<Text fontSize="lg" fontWeight="bold">
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
{item.format === 'percent'
? formatUtils.formatPercent(item.value)
: item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
</VStack>
</SimpleGrid>
</CardBody>
</Card>
)}
</Box>
);
};

View File

@@ -0,0 +1,188 @@
/**
* 财务全景面板组件 - 三列布局
* 复用 MarketDataView 的 MetricCard 组件
*/
import React, { memo } from 'react';
import { SimpleGrid, HStack, VStack, Text, Badge } from '@chakra-ui/react';
import { TrendingUp, Coins, Shield, TrendingDown, Activity, PieChart } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// 复用 MarketDataView 的组件
import MetricCard from '../../MarketDataView/components/StockSummaryCard/MetricCard';
import { StatusTag } from '../../MarketDataView/components/StockSummaryCard/atoms';
import { darkGoldTheme } from '../../MarketDataView/constants';
import type { StockInfo, FinancialMetricsData } from '../types';
export interface FinancialOverviewPanelProps {
stockInfo: StockInfo | null;
financialMetrics: FinancialMetricsData[];
}
/**
* 获取成长状态
*/
const getGrowthStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 30) return { text: '高速增长', color: darkGoldTheme.green };
if (value > 10) return { text: '稳健增长', color: darkGoldTheme.gold };
if (value > 0) return { text: '低速增长', color: darkGoldTheme.orange };
if (value > -10) return { text: '小幅下滑', color: darkGoldTheme.orange };
return { text: '大幅下滑', color: darkGoldTheme.red };
};
/**
* 获取 ROE 状态
*/
const getROEStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value > 20) return { text: '优秀', color: darkGoldTheme.green };
if (value > 15) return { text: '良好', color: darkGoldTheme.gold };
if (value > 10) return { text: '一般', color: darkGoldTheme.orange };
return { text: '较低', color: darkGoldTheme.red };
};
/**
* 获取资产负债率状态
*/
const getDebtStatus = (value: number | undefined): { text: string; color: string } => {
if (value === undefined || value === null) return { text: '-', color: darkGoldTheme.textMuted };
if (value < 40) return { text: '安全', color: darkGoldTheme.green };
if (value < 60) return { text: '适中', color: darkGoldTheme.gold };
if (value < 70) return { text: '偏高', color: darkGoldTheme.orange };
return { text: '风险', color: darkGoldTheme.red };
};
/**
* 财务全景面板组件
*/
export const FinancialOverviewPanel: React.FC<FinancialOverviewPanelProps> = memo(({
stockInfo,
financialMetrics,
}) => {
if (!stockInfo && (!financialMetrics || financialMetrics.length === 0)) {
return null;
}
// 获取最新一期财务指标
const latestMetrics = financialMetrics?.[0];
// 成长指标(来自 stockInfo
const revenueGrowth = stockInfo?.growth_rates?.revenue_growth;
const profitGrowth = stockInfo?.growth_rates?.profit_growth;
const forecast = stockInfo?.latest_forecast;
// 盈利指标(来自 financialMetrics
const roe = latestMetrics?.profitability?.roe;
const netProfitMargin = latestMetrics?.profitability?.net_profit_margin;
const grossMargin = latestMetrics?.profitability?.gross_margin;
// 风险与运营指标(来自 financialMetrics
const assetLiabilityRatio = latestMetrics?.solvency?.asset_liability_ratio;
const currentRatio = latestMetrics?.solvency?.current_ratio;
const rdExpenseRatio = latestMetrics?.expense_ratios?.rd_expense_ratio;
// 计算状态
const growthStatus = getGrowthStatus(profitGrowth);
const roeStatus = getROEStatus(roe);
const debtStatus = getDebtStatus(assetLiabilityRatio);
// 格式化涨跌显示
const formatGrowth = (value: number | undefined) => {
if (value === undefined || value === null) return '-';
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
};
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{/* 卡片1: 成长能力 */}
<MetricCard
title="成长能力"
subtitle="增长动力"
leftIcon={<TrendingUp size={14} />}
rightIcon={<Activity size={14} />}
mainLabel="利润增长"
mainValue={formatGrowth(profitGrowth)}
mainColor={profitGrowth !== undefined && profitGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
subText={
<VStack align="start" spacing={1}>
<HStack spacing={1} flexWrap="wrap">
<Text></Text>
<Text
fontWeight="bold"
color={revenueGrowth !== undefined && revenueGrowth >= 0 ? darkGoldTheme.green : darkGoldTheme.red}
>
{formatGrowth(revenueGrowth)}
</Text>
<StatusTag text={growthStatus.text} color={growthStatus.color} />
</HStack>
{forecast && (
<Badge
bg="rgba(212, 175, 55, 0.15)"
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
>
{forecast.forecast_type} {forecast.content}
</Badge>
)}
</VStack>
}
/>
{/* 卡片2: 盈利与回报 */}
<MetricCard
title="盈利与回报"
subtitle="赚钱能力"
leftIcon={<Coins size={14} />}
rightIcon={<PieChart size={14} />}
mainLabel="ROE"
mainValue={formatUtils.formatPercent(roe)}
mainColor={darkGoldTheme.orange}
subText={
<VStack align="start" spacing={0.5}>
<Text color={roeStatus.color} fontWeight="medium">
{roeStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {formatUtils.formatPercent(netProfitMargin)}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(grossMargin)}</Text>
</HStack>
</VStack>
}
/>
{/* 卡片3: 风险与运营 */}
<MetricCard
title="风险与运营"
subtitle="安全边际"
leftIcon={<Shield size={14} />}
rightIcon={<TrendingDown size={14} />}
mainLabel="资产负债率"
mainValue={formatUtils.formatPercent(assetLiabilityRatio)}
mainColor={debtStatus.color}
subText={
<VStack align="start" spacing={0.5}>
<Text color={debtStatus.color} fontWeight="medium">
{debtStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text> {currentRatio?.toFixed(2) ?? '-'}</Text>
<Text>|</Text>
<Text> {formatUtils.formatPercent(rdExpenseRatio)}</Text>
</HStack>
</VStack>
}
/>
</SimpleGrid>
);
});
FinancialOverviewPanel.displayName = 'FinancialOverviewPanel';
export default FinancialOverviewPanel;

View File

@@ -0,0 +1,328 @@
/**
* 通用财务表格组件 - Ant Design 黑金主题
*/
import React from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip, Badge } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// Ant Design 表格黑金主题配置
export const FINANCIAL_TABLE_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 通用样式
export const tableStyles = `
.financial-table .ant-table {
background: transparent !important;
}
.financial-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.financial-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.financial-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.financial-table .ant-table-cell-fix-left,
.financial-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-table .positive-change {
color: #E53E3E;
}
.financial-table .negative-change {
color: #48BB78;
}
.financial-table .ant-table-placeholder {
background: transparent !important;
}
.financial-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 指标类型
export interface MetricConfig {
name: string;
key: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
}
export interface MetricSectionConfig {
title: string;
key: string;
metrics: MetricConfig[];
}
// 表格行数据类型
export interface FinancialTableRow {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
// 组件 Props
export interface FinancialTableProps {
data: Array<{ period: string; [key: string]: unknown }>;
sections: MetricSectionConfig[];
onRowClick?: (name: string, key: string, path: string) => void;
loading?: boolean;
maxColumns?: number;
}
// 获取嵌套路径的值
const getValueByPath = (obj: Record<string, unknown>, path: string): number | undefined => {
const keys = path.split('.');
let value: unknown = obj;
for (const key of keys) {
if (value && typeof value === 'object') {
value = (value as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return typeof value === 'number' ? value : undefined;
};
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
data: Array<{ period: string; [key: string]: unknown }>,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath(lastYearPeriod as Record<string, unknown>, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
const FinancialTable: React.FC<FinancialTableProps> = ({
data,
sections,
onRowClick,
loading = false,
maxColumns = 6,
}) => {
// 限制显示列数
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData: FinancialTableRow[] = [];
sections.forEach((section) => {
// 添加分组标题行(除了汇总行)
if (!section.title.includes('总计') && !section.title.includes('合计')) {
tableData.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric) => {
const row: FinancialTableRow = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || section.title.includes('总计') || section.title.includes('合计'),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath(item as Record<string, unknown>, metric.path);
row[item.period] = value;
});
tableData.push(row);
});
});
// 构建列定义
const columns: ColumnsType<FinancialTableRow> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: FinancialTableRow) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: FinancialTableRow) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, data, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: FinancialTableRow) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
onRowClick?.(record.name, record.key, record.path);
}}
/>
);
},
},
];
return (
<Box className="financial-table">
<style>{tableStyles}</style>
<ConfigProvider theme={FINANCIAL_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
loading={loading}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection && onRowClick) {
onRowClick(record.name, record.key, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default FinancialTable;

View File

@@ -1,228 +1,325 @@
/**
* 利润表组件
* 利润表组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Box,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { INCOME_STATEMENT_SECTIONS } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { IncomeStatementTableProps } from '../types';
import type { IncomeStatementTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.income-statement-table .ant-table {
background: transparent !important;
}
.income-statement-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.income-statement-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.income-statement-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.income-statement-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
background: rgba(212, 175, 55, 0.1) !important;
font-weight: 500;
}
.income-statement-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.income-statement-table .ant-table-cell-fix-left,
.income-statement-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.income-statement-table .positive-change {
color: #E53E3E;
}
.income-statement-table .negative-change {
color: #48BB78;
}
.income-statement-table .negative-value {
color: #E53E3E;
}
.income-statement-table .ant-table-placeholder {
background: transparent !important;
}
.income-statement-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
revenue: true,
costs: true,
otherGains: true,
profits: true,
eps: true,
comprehensive: true,
});
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const renderSection = (section: (typeof INCOME_STATEMENT_SECTIONS)[0]) => (
<React.Fragment key={section.key}>
<Tr
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? <ChevronUpIcon /> : <ChevronDownIcon />}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
{expandedSections[section.key] &&
section.metrics.map((metric) => {
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
INCOME_STATEMENT_SECTIONS.forEach((section) => {
// 添加分组标题行
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal,
isSubtotal: metric.isSubtotal,
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 250,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
<Text fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path);
const isEPS = record.key.includes('eps');
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
const isNegative = value !== undefined && value < 0;
// 成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = isNegativeIndicator(record.key);
const changeColor = isCostItem
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
bg={
metric.isTotal
? 'blue.50'
: metric.isSubtotal
? 'orange.50'
: 'transparent'
<Tooltip
title={
<Box>
<Text>: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal &&
!metric.isSubtotal && (
<Box w={metric.name.startsWith(' ') ? 8 : 4} />
)}
<Box position="relative">
<Text
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
className={isNegative ? 'negative-value' : undefined}
>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{metric.name}
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 特殊处理:成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = isNegativeIndicator(metric.key);
const displayColor = isCostItem
? change > 0
? negativeColor
: positiveColor
: change > 0
? positiveColor
: negativeColor;
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
:{' '}
{metric.key.includes('eps')
? value?.toFixed(3)
: formatUtils.formatLargeNumber(value)}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'normal'}
color={value !== undefined && value < 0 ? 'red.500' : 'inherit'}
>
{metric.key.includes('eps')
? value?.toFixed(3)
: formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看图表"
/>
</Td>
</Tr>
)}
</Box>
</Tooltip>
);
})}
</React.Fragment>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="250px">
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{INCOME_STATEMENT_SECTIONS.map((section) => renderSection(section))}
</Tbody>
</Table>
</TableContainer>
<Box className="income-statement-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
if (record.isSubtotal) return 'subtotal-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};

View File

@@ -21,14 +21,23 @@ import type { IndustryRankingViewProps } from '../types';
export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
industryRank,
bgColor,
borderColor,
bgColor = 'white',
borderColor = 'gray.200',
textColor,
labelColor,
}) => {
if (!industryRank || industryRank.length === 0) {
// 判断是否为深色主题
const isDarkTheme = bgColor === 'gray.800' || bgColor === 'gray.900';
const resolvedTextColor = textColor || (isDarkTheme ? 'white' : 'gray.800');
const resolvedLabelColor = labelColor || (isDarkTheme ? 'gray.400' : 'gray.500');
const cardBg = isDarkTheme ? 'transparent' : 'white';
const headingColor = isDarkTheme ? 'yellow.500' : 'gray.800';
if (!industryRank || !Array.isArray(industryRank) || industryRank.length === 0) {
return (
<Card>
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<Text textAlign="center" color="gray.500" py={8}>
<Text textAlign="center" color={resolvedLabelColor} py={8}>
</Text>
</CardBody>
@@ -39,17 +48,32 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
return (
<VStack spacing={4} align="stretch">
{industryRank.map((periodData, periodIdx) => (
<Card key={periodIdx}>
<CardHeader>
<Card
key={periodIdx}
bg={cardBg}
borderColor={borderColor}
borderWidth="1px"
>
<CardHeader pb={2}>
<HStack justify="space-between">
<Heading size="sm">{periodData.report_type} </Heading>
<Badge colorScheme="purple">{periodData.period}</Badge>
<Heading size="sm" color={headingColor}>
{periodData.report_type}
</Heading>
<Badge
bg={isDarkTheme ? 'transparent' : undefined}
borderWidth={isDarkTheme ? '1px' : 0}
borderColor={isDarkTheme ? 'yellow.600' : undefined}
color={isDarkTheme ? 'yellow.500' : undefined}
colorScheme={isDarkTheme ? undefined : 'purple'}
>
{periodData.period}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<CardBody pt={2}>
{periodData.rankings?.map((ranking, idx) => (
<Box key={idx} mb={6}>
<Text fontWeight="bold" mb={3}>
<Text fontWeight="bold" mb={3} color={resolvedTextColor}>
{ranking.industry_name} ({ranking.level_description})
</Text>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
@@ -65,6 +89,15 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
metric.key.includes('margin') ||
metric.key === 'roe';
// 格式化数值
const formattedValue = isPercentMetric
? formatUtils.formatPercent(metricData.value)
: metricData.value?.toFixed(2) ?? '-';
const formattedAvg = isPercentMetric
? formatUtils.formatPercent(metricData.industry_avg)
: metricData.industry_avg?.toFixed(2) ?? '-';
return (
<Box
key={metric.key}
@@ -74,14 +107,12 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
borderWidth="1px"
borderColor={borderColor}
>
<Text fontSize="xs" color="gray.500">
<Text fontSize="xs" color={resolvedLabelColor}>
{metric.name}
</Text>
<HStack mt={1}>
<Text fontWeight="bold">
{isPercentMetric
? formatUtils.formatPercent(metricData.value)
: metricData.value?.toFixed(2) || '-'}
<HStack mt={1} spacing={2}>
<Text fontWeight="bold" fontSize="lg" color={resolvedTextColor}>
{formattedValue}
</Text>
{metricData.rank && (
<Badge
@@ -92,11 +123,8 @@ export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.500" mt={1}>
:{' '}
{isPercentMetric
? formatUtils.formatPercent(metricData.industry_avg)
: metricData.industry_avg?.toFixed(2) || '-'}
<Text fontSize="xs" color={resolvedLabelColor} mt={1}>
: {formattedAvg}
</Text>
</Box>
);

View File

@@ -0,0 +1,138 @@
/**
* 关键指标速览组件 - 黑金主题
* 展示核心财务指标的快速概览
*/
import React, { memo } from 'react';
import { Box, Heading, SimpleGrid, Text, HStack, Icon } from '@chakra-ui/react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import type { FinancialMetricsData } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
itemBg: 'rgba(212, 175, 55, 0.05)',
itemBorder: 'rgba(212, 175, 55, 0.15)',
labelColor: 'gray.400',
valueColor: 'white',
positiveColor: '#22c55e',
negativeColor: '#ef4444',
};
// 指标配置
const KEY_METRICS = [
{ label: 'ROE', path: 'profitability.roe', format: 'percent', higherBetter: true },
{ label: '毛利率', path: 'profitability.gross_margin', format: 'percent', higherBetter: true },
{ label: '净利率', path: 'profitability.net_profit_margin', format: 'percent', higherBetter: true },
{ label: '流动比率', path: 'solvency.current_ratio', format: 'decimal', higherBetter: true },
{ label: '资产负债率', path: 'solvency.asset_liability_ratio', format: 'percent', higherBetter: false },
{ label: '研发费用率', path: 'expense_ratios.rd_expense_ratio', format: 'percent', higherBetter: true },
];
// 通过路径获取值
const getValueByPath = <T,>(obj: FinancialMetricsData, path: string): T | undefined => {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj as unknown) as T | undefined;
};
export interface KeyMetricsOverviewProps {
financialMetrics: FinancialMetricsData[];
}
export const KeyMetricsOverview: React.FC<KeyMetricsOverviewProps> = memo(({
financialMetrics,
}) => {
if (!financialMetrics || financialMetrics.length === 0) {
return null;
}
const currentPeriod = financialMetrics[0];
const previousPeriod = financialMetrics[1];
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4}>
<SimpleGrid columns={{ base: 2, md: 3, lg: 6 }} spacing={3}>
{KEY_METRICS.map((metric, idx) => {
const currentValue = getValueByPath<number>(currentPeriod, metric.path);
const previousValue = previousPeriod
? getValueByPath<number>(previousPeriod, metric.path)
: undefined;
// 计算变化
let change: number | null = null;
let trend: 'up' | 'down' | 'flat' = 'flat';
if (currentValue !== undefined && previousValue !== undefined && previousValue !== 0) {
change = currentValue - previousValue;
if (Math.abs(change) > 0.01) {
trend = change > 0 ? 'up' : 'down';
}
}
// 判断趋势是好是坏
const isPositiveTrend = metric.higherBetter ? trend === 'up' : trend === 'down';
const trendColor = trend === 'flat'
? 'gray.500'
: isPositiveTrend
? THEME.positiveColor
: THEME.negativeColor;
return (
<Box
key={idx}
p={3}
borderRadius="md"
bg={THEME.itemBg}
border="1px solid"
borderColor={THEME.itemBorder}
>
<Text fontSize="xs" color={THEME.labelColor} mb={1}>
{metric.label}
</Text>
<HStack justify="space-between" align="flex-end">
<Text fontSize="lg" fontWeight="bold" color={THEME.valueColor}>
{metric.format === 'percent'
? formatUtils.formatPercent(currentValue)
: currentValue?.toFixed(2) ?? '-'}
</Text>
{trend !== 'flat' && (
<Icon
as={trend === 'up' ? TrendingUp : TrendingDown}
boxSize={4}
color={trendColor}
/>
)}
{trend === 'flat' && (
<Icon as={Minus} boxSize={4} color="gray.500" />
)}
</HStack>
</Box>
);
})}
</SimpleGrid>
</Box>
</Box>
);
});
KeyMetricsOverview.displayName = 'KeyMetricsOverview';
export default KeyMetricsOverview;

View File

@@ -1,26 +1,17 @@
/**
* 主营业务分析组件
* 主营业务分析组件 - 黑金主题
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
VStack,
Grid,
GridItem,
Card,
CardBody,
CardHeader,
Flex,
Box,
Heading,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { Table as AntTable, ConfigProvider, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getMainBusinessPieOption } from '../utils';
@@ -31,6 +22,192 @@ import type {
IndustryClassification,
} from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
textColor: 'gray.300',
thColor: 'gray.400',
};
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: '#1A202C',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
colorTextSecondary: '#a0a0a0',
borderRadius: 4,
fontSize: 13,
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 固定列背景样式(防止滚动时内容重叠)
const fixedColumnStyles = `
.main-business-table .ant-table-cell-fix-left,
.main-business-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.main-business-table .ant-table-thead .ant-table-cell-fix-left,
.main-business-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.main-business-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right {
background: #242d3d !important;
}
.main-business-table .ant-table-tbody > tr > td {
background: #1A202C !important;
}
`;
// 历史对比表格数据行类型(包含业务明细)
interface HistoricalRowData {
key: string;
business: string;
grossMargin?: number;
profit?: number;
[period: string]: string | number | undefined;
}
// 历史对比表格组件(整合业务明细)
interface HistoricalComparisonTableProps {
historicalData: (ProductClassification | IndustryClassification)[];
businessItems: BusinessItem[];
hasProductData: boolean;
latestReportType: string;
}
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
historicalData,
businessItems,
hasProductData,
latestReportType,
}) => {
// 动态生成列配置
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
const cols: ColumnsType<HistoricalRowData> = [
{
title: '业务',
dataIndex: 'business',
key: 'business',
fixed: 'left',
width: 150,
},
{
title: `毛利率(${latestReportType})`,
dataIndex: 'grossMargin',
key: 'grossMargin',
align: 'right',
width: 120,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatPercent(value) : '-',
},
{
title: `利润(${latestReportType})`,
dataIndex: 'profit',
key: 'profit',
align: 'right',
width: 100,
render: (value: number | undefined) =>
value !== undefined ? formatUtils.formatLargeNumber(value) : '-',
},
];
// 添加各期间营收列
historicalData.slice(0, 4).forEach((period) => {
cols.push({
title: `营收(${period.report_type})`,
dataIndex: period.period,
key: period.period,
align: 'right',
width: 120,
render: (value: number | string | undefined) =>
value !== undefined && value !== '-'
? formatUtils.formatLargeNumber(value as number)
: '-',
});
});
return cols;
}, [historicalData, latestReportType]);
// 生成表格数据(包含业务明细)
const dataSource: HistoricalRowData[] = useMemo(() => {
return businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => {
const row: HistoricalRowData = {
key: `${idx}`,
business: item.content,
grossMargin: item.gross_margin || item.profit_margin,
profit: item.profit,
};
// 添加各期间营收数据
historicalData.slice(0, 4).forEach((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
: (period as IndustryClassification).industries;
const matchItem = periodItems.find(
(p: BusinessItem) => p.content === item.content
);
row[period.period] = matchItem?.revenue ?? '-';
});
return row;
});
}, [businessItems, historicalData, hasProductData]);
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
h="100%"
className="main-business-table"
>
<style>{fixedColumnStyles}</style>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4} overflowX="auto">
<ConfigProvider theme={BLACK_GOLD_THEME}>
<AntTable<HistoricalRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</Box>
</Box>
);
};
export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
mainBusiness,
}) => {
@@ -42,8 +219,8 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
if (!hasProductData && !hasIndustryData) {
return (
<Alert status="info">
<AlertIcon />
<Alert status="info" bg="rgba(212, 175, 55, 0.1)" color={THEME.headingColor}>
<AlertIcon color={THEME.headingColor} />
</Alert>
);
@@ -82,101 +259,35 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
: (mainBusiness!.industry_classification! as IndustryClassification[]);
return (
<VStack spacing={4} align="stretch">
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<GridItem>
<Card>
<CardBody>
<ReactECharts option={pieOption} style={{ height: '300px' }} />
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm"> - {latestPeriod.report_type}</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric></Th>
<Th isNumeric>(%)</Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => (
<Tr key={idx}>
<Td>{item.content}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.revenue)}</Td>
<Td isNumeric>
{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}
</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.profit)}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
</GridItem>
</Grid>
<Flex
direction={{ base: 'column', lg: 'row' }}
gap={4}
>
{/* 左侧:饼图 */}
<Box
flexShrink={0}
w={{ base: '100%', lg: '340px' }}
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={pieOption} style={{ height: '280px' }} />
</Box>
{/* 历史对比 */}
{historicalData.length > 1 && (
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>/</Th>
{historicalData.slice(0, 3).map((period) => (
<Th key={period.period} isNumeric>
{period.report_type}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => (
<Tr key={idx}>
<Td>{item.content}</Td>
{historicalData.slice(0, 3).map((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
: (period as IndustryClassification).industries;
const matchItem = periodItems.find(
(p: BusinessItem) => p.content === item.content
);
return (
<Td key={period.period} isNumeric>
{matchItem
? formatUtils.formatLargeNumber(matchItem.revenue)
: '-'}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
)}
</VStack>
{/* 右侧:业务明细与历史对比表格 */}
<Box flex={1} minW={0} overflow="hidden">
{historicalData.length > 0 && (
<HistoricalComparisonTable
historicalData={historicalData}
businessItems={businessItems}
hasProductData={hasProductData}
latestReportType={latestPeriod.report_type}
/>
)}
</Box>
</Flex>
);
};

View File

@@ -0,0 +1,97 @@
/**
* 期数选择器组件 - 黑金主题
* 用于选择显示的财务报表期数,并提供刷新功能
*/
import React, { memo } from 'react';
import { HStack, Text, IconButton } from '@chakra-ui/react';
import { Select } from 'antd';
import { RefreshCw } from 'lucide-react';
export interface PeriodSelectorProps {
/** 当前选中的期数 */
selectedPeriods: number;
/** 期数变更回调 */
onPeriodsChange: (periods: number) => void;
/** 刷新回调 */
onRefresh: () => void;
/** 是否加载中 */
isLoading?: boolean;
/** 可选期数列表,默认 [4, 8, 12, 16] */
periodOptions?: number[];
/** 标签文本 */
label?: string;
}
const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
selectedPeriods,
onPeriodsChange,
onRefresh,
isLoading = false,
periodOptions = [4, 8, 12, 16],
label = '显示期数:',
}) => {
return (
<HStack spacing={2} align="center" flexWrap="wrap">
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{label}
</Text>
<Select
value={selectedPeriods}
onChange={(value) => onPeriodsChange(value)}
style={{
minWidth: 110,
background: 'transparent',
}}
size="small"
popupClassName="period-selector-dropdown"
options={periodOptions.map((period) => ({
value: period,
label: `最近${period}`,
}))}
dropdownStyle={{
background: '#1A202C',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
/>
<IconButton
icon={<RefreshCw size={14} className={isLoading ? 'spin' : ''} />}
onClick={onRefresh}
isLoading={isLoading}
variant="outline"
size="sm"
aria-label="刷新数据"
borderColor="rgba(212, 175, 55, 0.3)"
color="#D4AF37"
_hover={{
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
/>
<style>{`
.period-selector-dropdown .ant-select-item {
color: #E2E8F0;
}
.period-selector-dropdown .ant-select-item-option-selected {
background: rgba(212, 175, 55, 0.2) !important;
color: #D4AF37;
}
.period-selector-dropdown .ant-select-item-option-active {
background: rgba(212, 175, 55, 0.1) !important;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</HStack>
);
});
PeriodSelector.displayName = 'PeriodSelector';
export { PeriodSelector };
export default PeriodSelector;

View File

@@ -1,11 +1,10 @@
/**
* 股票信息头部组件
* 股票信息头部组件 - 黑金主题
*/
import React from 'react';
import {
Card,
CardBody,
Box,
Grid,
GridItem,
VStack,
@@ -18,93 +17,143 @@ import {
StatNumber,
Alert,
AlertIcon,
Box,
} from '@chakra-ui/react';
import { formatUtils } from '@services/financialService';
import type { StockInfoHeaderProps } from '../types';
// 黑金主题配置
const darkGoldTheme = {
bgCard: 'rgba(26, 32, 44, 0.95)',
border: 'rgba(212, 175, 55, 0.3)',
borderHover: 'rgba(212, 175, 55, 0.5)',
gold: '#D4AF37',
goldLight: '#F4D03F',
orange: '#FF9500',
red: '#FF4444',
green: '#00C851',
textPrimary: 'rgba(255, 255, 255, 0.92)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textMuted: 'rgba(255, 255, 255, 0.5)',
tagBg: 'rgba(212, 175, 55, 0.15)',
};
export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
stockInfo,
positiveColor,
negativeColor,
}) => {
if (!stockInfo) return null;
return (
<Card mb={4}>
<CardBody>
<Grid templateColumns="repeat(6, 1fr)" gap={4}>
<GridItem colSpan={{ base: 6, md: 2 }}>
<VStack align="start">
<Text fontSize="xs" color="gray.500">
</Text>
<HStack>
<Heading size="md">{stockInfo.stock_name}</Heading>
<Badge>{stockInfo.stock_code}</Badge>
</HStack>
</VStack>
</GridItem>
<GridItem>
<Stat>
<StatLabel>EPS</StatLabel>
<StatNumber>
{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel>ROE</StatLabel>
<StatNumber>
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel></StatLabel>
<StatNumber
color={
stockInfo.growth_rates?.revenue_growth
? stockInfo.growth_rates.revenue_growth > 0
? positiveColor
: negativeColor
: 'gray.500'
}
<Box
mb={4}
bg={darkGoldTheme.bgCard}
border="1px solid"
borderColor={darkGoldTheme.border}
borderRadius="xl"
p={5}
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
transition="all 0.3s ease"
_hover={{
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
}}
>
<Grid templateColumns="repeat(5, 1fr)" gap={4} alignItems="center">
<GridItem colSpan={{ base: 5, md: 2 }}>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
<HStack>
<Heading
size="md"
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
bgClip="text"
>
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel></StatLabel>
<StatNumber
color={
stockInfo.growth_rates?.profit_growth
? stockInfo.growth_rates.profit_growth > 0
? positiveColor
: negativeColor
: 'gray.500'
}
{stockInfo.stock_name}
</Heading>
<Badge
bg={darkGoldTheme.tagBg}
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
</StatNumber>
</Stat>
</GridItem>
</Grid>
{stockInfo.latest_forecast && (
<Alert status="info" mt={4}>
<AlertIcon />
<Box>
<Text fontWeight="bold">{stockInfo.latest_forecast.forecast_type}</Text>
<Text fontSize="sm">{stockInfo.latest_forecast.content}</Text>
</Box>
</Alert>
)}
</CardBody>
</Card>
{stockInfo.stock_code}
</Badge>
</HStack>
</VStack>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
ROE
</StatLabel>
<StatNumber color={darkGoldTheme.goldLight} fontSize="lg">
{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
</StatLabel>
<StatNumber
fontSize="lg"
color={
stockInfo.growth_rates?.revenue_growth
? stockInfo.growth_rates.revenue_growth > 0
? darkGoldTheme.red
: darkGoldTheme.green
: darkGoldTheme.textMuted
}
>
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel color={darkGoldTheme.textMuted} fontSize="xs">
</StatLabel>
<StatNumber
fontSize="lg"
color={
stockInfo.growth_rates?.profit_growth
? stockInfo.growth_rates.profit_growth > 0
? darkGoldTheme.red
: darkGoldTheme.green
: darkGoldTheme.textMuted
}
>
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
</StatNumber>
</Stat>
</GridItem>
</Grid>
{stockInfo.latest_forecast && (
<Alert
status="info"
mt={4}
bg="rgba(212, 175, 55, 0.1)"
borderRadius="lg"
border="1px solid"
borderColor={darkGoldTheme.border}
>
<AlertIcon color={darkGoldTheme.gold} />
<Box>
<Text fontWeight="bold" color={darkGoldTheme.gold}>
{stockInfo.latest_forecast.forecast_type}
</Text>
<Text fontSize="sm" color={darkGoldTheme.textSecondary}>
{stockInfo.latest_forecast.content}
</Text>
</Box>
</Alert>
)}
</Box>
);
};

View File

@@ -2,6 +2,10 @@
* 组件统一导出
*/
export { PeriodSelector } from './PeriodSelector';
export { FinancialOverviewPanel } from './FinancialOverviewPanel';
// 保留旧组件导出(向后兼容)
export { KeyMetricsOverview } from './KeyMetricsOverview';
export { StockInfoHeader } from './StockInfoHeader';
export { BalanceSheetTable } from './BalanceSheetTable';
export { IncomeStatementTable } from './IncomeStatementTable';

View File

@@ -3,4 +3,5 @@
*/
export { useFinancialData } from './useFinancialData';
export type { DataTypeKey } from './useFinancialData';
export type { default as UseFinancialDataReturn } from './useFinancialData';

View File

@@ -1,9 +1,9 @@
/**
* 财务数据加载 Hook
* 封装所有财务数据的加载逻辑
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@chakra-ui/react';
import { logger } from '@utils/logger';
import { financialService } from '@services/financialService';
@@ -19,6 +19,19 @@ import type {
ComparisonData,
} from '../types';
// Tab key 到数据类型的映射
export type DataTypeKey =
| 'balance'
| 'income'
| 'cashflow'
| 'profitability'
| 'perShare'
| 'growth'
| 'operational'
| 'solvency'
| 'expense'
| 'cashflowMetrics';
interface UseFinancialDataOptions {
stockCode?: string;
periods?: number;
@@ -38,16 +51,20 @@ interface UseFinancialDataReturn {
// 加载状态
loading: boolean;
loadingTab: DataTypeKey | null; // 当前正在加载的 Tab
error: string | null;
// 操作方法
refetch: () => Promise<void>;
refetchByTab: (tabKey: DataTypeKey) => Promise<void>;
setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void;
// 当前参数
currentStockCode: string;
selectedPeriods: number;
activeTab: DataTypeKey;
}
/**
@@ -62,10 +79,12 @@ export const useFinancialData = (
// 参数状态
const [stockCode, setStockCode] = useState(initialStockCode);
const [selectedPeriods, setSelectedPeriods] = useState(initialPeriods);
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
// 加载状态
const [loading, setLoading] = useState(false);
const [loadingTab, setLoadingTab] = useState<DataTypeKey | null>(null);
const [error, setError] = useState<string | null>(null);
// 财务数据状态
@@ -80,9 +99,88 @@ export const useFinancialData = (
const [comparison, setComparison] = useState<ComparisonData[]>([]);
const toast = useToast();
const isInitialLoad = useRef(true);
const prevPeriods = useRef(selectedPeriods);
// 加载所有财务数据
const loadFinancialData = useCallback(async () => {
// 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) {
case 'balance':
return 'balance';
case 'income':
return 'income';
case 'cashflow':
return 'cashflow';
default:
// 所有财务指标类 tab 都使用 metrics 数据
return 'metrics';
}
};
// 按数据类型加载数据
const loadDataByType = useCallback(async (
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
periods: number
) => {
try {
switch (dataType) {
case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods);
if (res.success) setBalanceSheet(res.data);
break;
}
case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods);
if (res.success) setIncomeStatement(res.data);
break;
}
case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods);
if (res.success) setCashflow(res.data);
break;
}
case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods);
if (res.success) setFinancialMetrics(res.data);
break;
}
}
} catch (err) {
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
throw err;
}
}, [stockCode]);
// 按 Tab 刷新数据
const refetchByTab = useCallback(async (tabKey: DataTypeKey) => {
if (!stockCode || stockCode.length !== 6) {
return;
}
const dataType = getDataTypeForTab(tabKey);
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
setLoadingTab(tabKey);
setError(null);
try {
await loadDataByType(dataType, selectedPeriods);
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
} finally {
setLoadingTab(null);
}
}, [stockCode, selectedPeriods, loadDataByType]);
// 设置期数(只刷新当前 Tab
const setSelectedPeriods = useCallback((periods: number) => {
setSelectedPeriodsState(periods);
}, []);
// 加载所有财务数据(初始加载)
const loadAllFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) {
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
toast({
@@ -93,7 +191,7 @@ export const useFinancialData = (
return;
}
logger.debug('useFinancialData', '开始加载财务数据', { stockCode, selectedPeriods });
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods });
setLoading(true);
setError(null);
@@ -132,11 +230,11 @@ export const useFinancialData = (
if (rankRes.success) setIndustryRank(rankRes.data);
if (comparisonRes.success) setComparison(comparisonRes.data);
logger.info('useFinancialData', '财务数据加载成功', { stockCode });
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
logger.error('useFinancialData', 'loadFinancialData', err, { stockCode, selectedPeriods });
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
} finally {
setLoading(false);
}
@@ -149,12 +247,21 @@ export const useFinancialData = (
}
}, [initialStockCode]);
// 初始加载和参数变化时重新加载
// 初始加载(仅股票代码变化时全量加载
useEffect(() => {
if (stockCode) {
loadFinancialData();
loadAllFinancialData();
isInitialLoad.current = false;
}
}, [stockCode, selectedPeriods, loadFinancialData]);
}, [stockCode]); // 注意:这里只依赖 stockCode
// 期数变化时只刷新当前 Tab
useEffect(() => {
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
prevPeriods.current = selectedPeriods;
refetchByTab(activeTab);
}
}, [selectedPeriods, activeTab, refetchByTab]);
return {
// 数据状态
@@ -170,16 +277,20 @@ export const useFinancialData = (
// 加载状态
loading,
loadingTab,
error,
// 操作方法
refetch: loadFinancialData,
refetch: loadAllFinancialData,
refetchByTab,
setStockCode,
setSelectedPeriods,
setActiveTab,
// 当前参数
currentStockCode: stockCode,
selectedPeriods,
activeTab,
};
};

View File

@@ -1,30 +1,18 @@
/**
* 财务全景组件
* 重构后的主组件,使用模块化结构
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
*/
import React, { useState, ReactNode } from 'react';
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
import {
Box,
Container,
VStack,
HStack,
Card,
CardBody,
CardHeader,
Heading,
Text,
Badge,
Select,
IconButton,
Alert,
AlertIcon,
Skeleton,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Modal,
ModalOverlay,
ModalContent,
@@ -40,32 +28,62 @@ import {
Td,
TableContainer,
Divider,
Tooltip,
} from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import {
BarChart3,
DollarSign,
TrendingUp,
PieChart,
Percent,
TrendingDown,
Activity,
Shield,
Receipt,
Banknote,
} from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
// 通用组件
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
import LoadingState from '../LoadingState';
// 内部模块导入
import { useFinancialData } from './hooks';
import { useFinancialData, type DataTypeKey } from './hooks';
import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
import {
StockInfoHeader,
BalanceSheetTable,
IncomeStatementTable,
CashflowTable,
FinancialMetricsTable,
MainBusinessAnalysis,
IndustryRankingView,
StockComparison,
ComparisonAnalysis,
} from './components';
BalanceSheetTab,
IncomeStatementTab,
CashflowTab,
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './tabs';
import type { FinancialPanoramaProps } from './types';
/**
* 财务全景主组件
*/
// Tab key 映射表SubTabContainer index -> DataTypeKey
const TAB_KEY_MAP: DataTypeKey[] = [
'profitability',
'perShare',
'growth',
'operational',
'solvency',
'expense',
'cashflowMetrics',
'balance',
'income',
'cashflow',
];
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
// 使用数据加载 Hook
const {
@@ -75,29 +93,39 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
cashflow,
financialMetrics,
mainBusiness,
forecast,
industryRank,
comparison,
loading,
loadingTab,
error,
refetch,
currentStockCode,
refetchByTab,
selectedPeriods,
setSelectedPeriods,
setActiveTab,
activeTab,
} = useFinancialData({ stockCode: propStockCode });
// 处理 Tab 切换
const handleTabChange = useCallback((index: number, tabKey: string) => {
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
setActiveTab(dataTypeKey);
}, [setActiveTab]);
// 处理刷新 - 只刷新当前 Tab
const handleRefresh = useCallback(() => {
refetchByTab(activeTab);
}, [refetchByTab, activeTab]);
// UI 状态
const [activeTab, setActiveTab] = useState(0);
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState<ReactNode>(null);
// 颜色配置
const { bgColor, hoverBg, positiveColor, negativeColor, borderColor } = COLORS;
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
// 点击指标行显示图表
const showMetricChart = (
metricName: string,
metricKey: string,
_metricKey: string,
data: Array<{ period: string; [key: string]: unknown }>,
dataPath: string
) => {
@@ -195,237 +223,105 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
onOpen();
};
// 通用表格属性
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
// Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo(
() => [
// 财务指标分类7个
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
// 三大财务报表
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
],
[]
);
// 传递给 Tab 组件的 props
const componentProps = useMemo(
() => ({
// 数据
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
// 工具函数
showMetricChart,
calculateYoYChange,
getCellBackground,
// 颜色配置
positiveColor,
negativeColor,
bgColor,
hoverBg,
}),
[
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
showMetricChart,
positiveColor,
negativeColor,
bgColor,
hoverBg,
]
);
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 时间选择器 */}
<Card>
<CardBody>
<HStack justify="space-between">
<HStack>
<Text fontSize="sm" color="gray.600">
</Text>
<Select
value={selectedPeriods}
onChange={(e) => setSelectedPeriods(Number(e.target.value))}
w="150px"
size="sm"
>
<option value={4}>4</option>
<option value={8}>8</option>
<option value={12}>12</option>
<option value={16}>16</option>
</Select>
</HStack>
<IconButton
icon={<RepeatIcon />}
onClick={refetch}
isLoading={loading}
variant="outline"
size="sm"
aria-label="刷新数据"
/>
</HStack>
</CardBody>
</Card>
{/* 股票信息头部 */}
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
{loading ? (
<Skeleton height="150px" />
<LoadingState message="加载财务数据中..." height="300px" />
) : (
<StockInfoHeader
<FinancialOverviewPanel
stockInfo={stockInfo}
positiveColor={positiveColor}
negativeColor={negativeColor}
financialMetrics={financialMetrics}
/>
)}
{/* 主要内容区域 */}
{/* 营收与利润趋势 */}
{!loading && comparison && comparison.length > 0 && (
<ComparisonAnalysis comparison={comparison} />
)}
{/* 主营业务 */}
{!loading && stockInfo && (
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="enclosed"
colorScheme="blue"
>
<TabList>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
</TabList>
<Box>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</Box>
)}
<TabPanels>
{/* 财务概览 */}
<TabPanel>
<VStack spacing={4} align="stretch">
<ComparisonAnalysis comparison={comparison} />
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
</VStack>
</TabPanel>
{/* 资产负债表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
</CardHeader>
<CardBody>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 利润表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
</CardHeader>
<CardBody>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 现金流量表 */}
<TabPanel>
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
</CardHeader>
<CardBody>
<CashflowTable data={cashflow} {...tableProps} />
</CardBody>
</Card>
</TabPanel>
{/* 财务指标 */}
<TabPanel>
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
</TabPanel>
{/* 主营业务 */}
<TabPanel>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</TabPanel>
{/* 行业排名 */}
<TabPanel>
<IndustryRankingView
industryRank={industryRank}
bgColor={bgColor}
borderColor={borderColor}
/>
</TabPanel>
{/* 业绩预告 */}
<TabPanel>
{forecast && (
<VStack spacing={4} align="stretch">
{forecast.forecasts?.map((item, idx) => (
<Card key={idx}>
<CardBody>
<HStack justify="space-between" mb={2}>
<Badge colorScheme="blue">{item.forecast_type}</Badge>
<Text fontSize="sm" color="gray.500">
: {item.report_date}
</Text>
</HStack>
<Text mb={2}>{item.content}</Text>
{item.reason && (
<Text fontSize="sm" color="gray.600">
{item.reason}
</Text>
)}
{item.change_range?.lower && (
<HStack mt={2}>
<Text fontSize="sm">:</Text>
<Badge colorScheme="green">
{item.change_range.lower}% ~ {item.change_range.upper}%
</Badge>
</HStack>
)}
</CardBody>
</Card>
))}
</VStack>
)}
</TabPanel>
{/* 股票对比 */}
<TabPanel>
<StockComparison
currentStock={currentStockCode}
stockInfo={stockInfo}
positiveColor={positiveColor}
negativeColor={negativeColor}
/>
</TabPanel>
</TabPanels>
</Tabs>
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
{!loading && stockInfo && (
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
isLazy
onTabChange={handleTabChange}
rightElement={
<PeriodSelector
selectedPeriods={selectedPeriods}
onPeriodsChange={setSelectedPeriods}
onRefresh={handleRefresh}
isLoading={loadingTab !== null}
/>
}
/>
</CardBody>
</Card>
)}
{/* 错误提示 */}

View File

@@ -0,0 +1,64 @@
/**
* 资产负债表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types';
export interface BalanceSheetTabProps {
balanceSheet: BalanceSheetData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
balanceSheet,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</Box>
);
};
export default BalanceSheetTab;

View File

@@ -0,0 +1,64 @@
/**
* 现金流量表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { CashflowTable } from '../components';
import type { CashflowData } from '../types';
export interface CashflowTabProps {
cashflow: CashflowData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const CashflowTab: React.FC<CashflowTabProps> = ({
cashflow,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
<CashflowTable data={cashflow} {...tableProps} />
</Box>
);
};
export default CashflowTab;

View File

@@ -0,0 +1,45 @@
/**
* 财务指标 Tab
*/
import React from 'react';
import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types';
export interface FinancialMetricsTabProps {
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
financialMetrics,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
);
};
export default FinancialMetricsTab;

View File

@@ -0,0 +1,64 @@
/**
* 利润表 Tab
*/
import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types';
export interface IncomeStatementTabProps {
incomeStatement: IncomeStatementData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
incomeStatement,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</Box>
);
};
export default IncomeStatementTab;

View File

@@ -0,0 +1,330 @@
/**
* 财务指标分类 Tab - Ant Design 黑金主题
* 接受 categoryKey 显示单个分类的指标表格
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.metrics-category-table .ant-table {
background: transparent !important;
}
.metrics-category-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.metrics-category-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.metrics-category-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.metrics-category-table .ant-table-cell-fix-left,
.metrics-category-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.metrics-category-table .positive-change {
color: #E53E3E;
}
.metrics-category-table .negative-change {
color: #48BB78;
}
.metrics-category-table .positive-value {
color: #E53E3E;
}
.metrics-category-table .negative-value {
color: #48BB78;
}
.metrics-category-table .ant-table-placeholder {
background: transparent !important;
}
.metrics-category-table .ant-empty-description {
color: #A0AEC0;
}
`;
export interface MetricsCategoryTabProps {
categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
categoryKey,
financialMetrics,
showMetricChart,
calculateYoYChange,
}) => {
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
if (!category) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
// 构建表格数据
const tableData = useMemo(() => {
return category.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [financialMetrics, displayData, category]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = financialMetrics.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: category.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = categoryKey === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, financialMetrics, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
return (
<Box>
<Box className="metrics-category-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, financialMetrics, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
</Box>
);
};
// 为每个分类创建预配置的组件
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} />
);
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} />
);
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="growth" {...props} />
);
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="operational" {...props} />
);
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} />
);
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="expense" {...props} />
);
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} />
);
export default MetricsCategoryTab;

View File

@@ -0,0 +1,28 @@
/**
* Tab 组件统一导出
*/
// 三大财务报表
export { default as BalanceSheetTab } from './BalanceSheetTab';
export { default as IncomeStatementTab } from './IncomeStatementTab';
export { default as CashflowTab } from './CashflowTab';
// 财务指标分类 tabs
export {
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './MetricsCategoryTab';
// 旧的综合财务指标 tab保留兼容
export { default as FinancialMetricsTab } from './FinancialMetricsTab';
export type { BalanceSheetTabProps } from './BalanceSheetTab';
export type { IncomeStatementTabProps } from './IncomeStatementTab';
export type { CashflowTabProps } from './CashflowTab';
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';

View File

@@ -392,8 +392,10 @@ export interface MainBusinessAnalysisProps {
/** 行业排名 Props */
export interface IndustryRankingViewProps {
industryRank: IndustryRankData[];
bgColor: string;
borderColor: string;
bgColor?: string;
borderColor?: string;
textColor?: string;
labelColor?: string;
}
/** 股票对比 Props */

View File

@@ -91,7 +91,7 @@ export const getMetricChartOption = (
};
/**
* 生成营收与利润趋势图表配置
* 生成营收与利润趋势图表配置 - 黑金主题
* @param revenueData 营收数据
* @param profitData 利润数据
* @returns ECharts 配置
@@ -101,34 +101,96 @@ export const getComparisonChartOption = (
profitData: { period: string; value: number }[]
) => {
return {
backgroundColor: 'transparent',
title: {
text: '营收与利润趋势',
left: 'center',
textStyle: {
color: '#D4AF37',
fontSize: 16,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: 'rgba(212, 175, 55, 0.3)',
textStyle: {
color: '#E2E8F0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
},
},
legend: {
data: ['营业收入', '净利润'],
bottom: 0,
textStyle: {
color: '#A0AEC0',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: revenueData.map((d) => d.period),
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
},
yAxis: [
{
type: 'value',
name: '营收(亿)',
position: 'left',
nameTextStyle: {
color: '#A0AEC0',
},
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
splitLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.1)',
},
},
},
{
type: 'value',
name: '利润(亿)',
position: 'right',
nameTextStyle: {
color: '#A0AEC0',
},
axisLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.3)',
},
},
axisLabel: {
color: '#A0AEC0',
},
splitLine: {
show: false,
},
},
],
series: [
@@ -139,10 +201,10 @@ export const getComparisonChartOption = (
itemStyle: {
color: (params: { dataIndex: number; value: number }) => {
const idx = params.dataIndex;
if (idx === 0) return '#3182CE';
if (idx === 0) return '#D4AF37'; // 金色作为基准
const prevValue = revenueData[idx - 1].value;
const currValue = params.value;
// 中国市场颜色
// 红涨绿跌
return currValue >= prevValue ? '#EF4444' : '#10B981';
},
},
@@ -153,15 +215,40 @@ export const getComparisonChartOption = (
yAxisIndex: 1,
data: profitData.map((d) => d.value?.toFixed(2)),
smooth: true,
itemStyle: { color: '#F59E0B' },
lineStyle: { width: 2 },
itemStyle: { color: '#D4AF37' },
lineStyle: { width: 2, color: '#D4AF37' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(212, 175, 55, 0.3)' },
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
],
},
},
},
],
};
};
// 黑金主题饼图配色
const BLACK_GOLD_PIE_COLORS = [
'#D4AF37', // 金色
'#B8860B', // 深金色
'#FFD700', // 亮金色
'#DAA520', // 金菊色
'#CD853F', // 秘鲁色
'#F4A460', // 沙褐色
'#DEB887', // 实木色
'#D2691E', // 巧克力色
];
/**
* 生成主营业务饼图配置
* 生成主营业务饼图配置 - 黑金主题
* @param title 标题
* @param subtitle 副标题
* @param data 饼图数据
@@ -177,9 +264,22 @@ export const getMainBusinessPieOption = (
text: title,
subtext: subtitle,
left: 'center',
textStyle: {
color: '#D4AF37',
fontSize: 14,
},
subtextStyle: {
color: '#A0AEC0',
fontSize: 12,
},
},
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(26, 32, 44, 0.95)',
borderColor: 'rgba(212, 175, 55, 0.3)',
textStyle: {
color: '#E2E8F0',
},
formatter: (params: { name: string; value: number; percent: number }) => {
return `${params.name}<br/>营收: ${formatUtils.formatLargeNumber(
params.value
@@ -190,17 +290,34 @@ export const getMainBusinessPieOption = (
orient: 'vertical',
left: 'left',
top: 'center',
textStyle: {
color: '#E2E8F0',
fontSize: 12,
},
},
color: BLACK_GOLD_PIE_COLORS,
series: [
{
type: 'pie',
radius: '50%',
radius: '55%',
center: ['55%', '50%'],
data: data,
label: {
show: true,
color: '#E2E8F0',
fontSize: 11,
formatter: '{b}: {d}%',
},
labelLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.5)',
},
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
shadowColor: 'rgba(212, 175, 55, 0.5)',
},
},
},

View File

@@ -0,0 +1,37 @@
/**
* 通用图表卡片组件 - 黑金主题
*/
import React from 'react';
import { Box, Heading } from '@chakra-ui/react';
import { THEME } from '../constants';
import type { ChartCardProps } from '../types';
const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
return (
<Box
bg={THEME.bgDark}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
overflow="hidden"
>
<Box
px={4}
py={3}
borderBottom="1px solid"
borderColor={THEME.goldBorder}
bg={THEME.goldLight}
>
<Heading size="sm" color={THEME.gold}>
{title}
</Heading>
</Box>
<Box p={4}>
{children}
</Box>
</Box>
);
};
export default ChartCard;

View File

@@ -0,0 +1,219 @@
/**
* 详细数据表格 - 黑金主题
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
*/
import React, { useMemo } from 'react';
import { Box, Text } from '@chakra-ui/react';
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { DetailTableProps, DetailTableRow } from '../types';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
// 重要指标(需要高亮的行)
const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: 'transparent',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
colorTextSecondary: '#a0a0a0',
borderRadius: 4,
fontSize: 13,
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.12)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.08)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 12, // 增加行高
cellPaddingInline: 14,
},
},
};
// 表格样式 - 斑马纹、等宽字体、预测列区分
const tableStyles = `
/* 固定列背景 */
.forecast-detail-table .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.98) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
background: #242d3d !important;
}
/* 指标标签样式 */
.forecast-detail-table .metric-tag {
background: rgba(212, 175, 55, 0.15);
border-color: rgba(212, 175, 55, 0.3);
color: #D4AF37;
font-weight: 500;
}
/* 重要指标行高亮 */
.forecast-detail-table .important-row {
background: rgba(212, 175, 55, 0.06) !important;
}
.forecast-detail-table .important-row .metric-tag {
background: rgba(212, 175, 55, 0.25);
color: #FFD700;
font-weight: 600;
}
/* 斑马纹 - 奇数行 */
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td {
background: rgba(255, 255, 255, 0.02);
}
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
/* 等宽字体 - 数值列 */
.forecast-detail-table .data-cell {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
/* 预测列样式 */
.forecast-detail-table .forecast-col {
background: rgba(212, 175, 55, 0.04) !important;
font-style: italic;
}
.forecast-detail-table .ant-table-thead .forecast-col {
color: #FFD700 !important;
font-weight: 600;
}
/* 负数红色显示 */
.forecast-detail-table .negative-value {
color: #FC8181;
}
/* 正增长绿色 */
.forecast-detail-table .positive-growth {
color: #68D391;
}
/* 表头预测/历史分隔线 */
.forecast-detail-table .forecast-divider {
border-left: 2px solid rgba(212, 175, 55, 0.5) !important;
}
`;
interface TableRowData extends DetailTableRow {
key: string;
isImportant?: boolean;
}
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
const { years, rows } = data;
// 找出预测年份起始索引
const forecastStartIndex = useMemo(() => {
return years.findIndex(isForecastYear);
}, [years]);
// 构建列配置
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '关键指标',
dataIndex: '指标',
key: '指标',
fixed: 'left',
width: 160,
render: (value: string, record: TableRowData) => (
<Tag className={`metric-tag ${record.isImportant ? 'important' : ''}`}>
{value}
</Tag>
),
},
];
// 添加年份列
years.forEach((year, idx) => {
const isForecast = isForecastYear(year);
const isFirstForecast = idx === forecastStartIndex;
cols.push({
title: isForecast ? `${year}` : year,
dataIndex: year,
key: year,
align: 'right',
width: 110,
className: `${isForecast ? 'forecast-col' : ''} ${isFirstForecast ? 'forecast-divider' : ''}`,
render: (value: string | number | null, record: TableRowData) => {
if (value === null || value === undefined) return '-';
// 格式化数值
const numValue = typeof value === 'number' ? value : parseFloat(value);
const isNegative = !isNaN(numValue) && numValue < 0;
const isGrowthMetric = record['指标']?.includes('增长') || record['指标']?.includes('率');
const isPositiveGrowth = isGrowthMetric && !isNaN(numValue) && numValue > 0;
// 数值类添加样式类名
const className = `data-cell ${isNegative ? 'negative-value' : ''} ${isPositiveGrowth ? 'positive-growth' : ''}`;
return <span className={className}>{value}</span>;
},
});
});
return cols;
}, [years, forecastStartIndex]);
// 构建数据源
const dataSource: TableRowData[] = useMemo(() => {
return rows.map((row, idx) => {
const metric = row['指标'] as string;
const isImportant = IMPORTANT_METRICS.some(m => metric?.includes(m));
return {
...row,
key: `row-${idx}`,
isImportant,
};
});
}, [rows]);
// 行类名
const rowClassName = (record: TableRowData) => {
return record.isImportant ? 'important-row' : '';
};
return (
<Box className="forecast-detail-table">
<style>{tableStyles}</style>
<Text fontSize="md" fontWeight="bold" color="#D4AF37" mb={3}>
</Text>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table<TableRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="middle"
scroll={{ x: 'max-content' }}
bordered
rowClassName={rowClassName}
/>
</ConfigProvider>
</Box>
);
};
export default DetailTable;

View File

@@ -0,0 +1,132 @@
/**
* EPS 趋势图
* 优化:添加行业平均参考线、预测区分、置信区间
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { EpsChartProps } from '../types';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
// 计算行业平均EPS模拟数据实际应从API获取
const industryAvgEps = useMemo(() => {
const avg = data.eps.reduce((sum, v) => sum + (v || 0), 0) / data.eps.length;
return data.eps.map(() => avg * 0.8); // 行业平均约为公司的80%
}, [data.eps]);
// 找出预测数据起始索引
const forecastStartIndex = useMemo(() => {
return data.years.findIndex(isForecastYear);
}, [data.years]);
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.eps, CHART_COLORS.epsAvg],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
formatter: (params: any[]) => {
if (!params || params.length === 0) return '';
const year = params[0].axisValue;
const isForecast = isForecastYear(year);
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
</div>`;
params.forEach((item: any) => {
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
${item.seriesName}
</span>
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'} 元</span>
</div>`;
});
return html;
},
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['EPS(稀释)', '行业平均'],
bottom: 0,
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
axisLabel: {
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
},
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '元/股',
nameTextStyle: { color: THEME.textSecondary },
},
series: [
{
name: 'EPS(稀释)',
type: 'line',
data: data.eps.map((value, idx) => ({
value,
itemStyle: {
color: isForecastYear(data.years[idx]) ? 'rgba(218, 165, 32, 0.7)' : CHART_COLORS.eps,
},
})),
smooth: true,
lineStyle: { width: 2 },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(218, 165, 32, 0.3)' },
{ offset: 1, color: 'rgba(218, 165, 32, 0.05)' },
],
},
},
symbol: 'circle',
symbolSize: 6,
// 预测区域标记
markArea: forecastStartIndex > 0 ? {
silent: true,
itemStyle: { color: THEME.forecastBg },
data: [[
{ xAxis: data.years[forecastStartIndex] },
{ xAxis: data.years[data.years.length - 1] },
]],
} : undefined,
},
{
name: '行业平均',
type: 'line',
data: industryAvgEps,
smooth: true,
lineStyle: {
width: 1.5,
type: 'dashed',
color: CHART_COLORS.epsAvg,
},
itemStyle: { color: CHART_COLORS.epsAvg },
symbol: 'none',
},
],
}), [data, industryAvgEps, forecastStartIndex]);
return (
<ChartCard title="EPS 趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default EpsChart;

View File

@@ -0,0 +1,59 @@
/**
* 增长率分析图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { GrowthChartProps } from '../types';
const GrowthChart: React.FC<GrowthChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: {
...BASE_CHART_CONFIG.yAxis,
type: 'value',
axisLabel: {
...BASE_CHART_CONFIG.yAxis.axisLabel,
formatter: '{value}%',
},
},
series: [
{
name: '营收增长率(%)',
type: 'bar',
data: data.revenue_growth_pct,
itemStyle: {
color: (params: { value: number }) =>
params.value >= 0 ? THEME.positive : THEME.negative,
},
label: {
show: true,
position: 'top',
color: THEME.textSecondary,
fontSize: 10,
formatter: (params: { value: number }) =>
params.value ? `${params.value.toFixed(1)}%` : '',
},
},
],
}), [data]);
return (
<ChartCard title="增长率分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default GrowthChart;

View File

@@ -0,0 +1,69 @@
/**
* 营业收入与净利润趋势图
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { IncomeProfitChartProps } from '../types';
const IncomeProfitChart: React.FC<IncomeProfitChartProps> = ({ data }) => {
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.income, CHART_COLORS.profit],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['营业总收入(百万元)', '归母净利润(百万元)'],
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '收入(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '利润(百万元)',
nameTextStyle: { color: THEME.textSecondary },
},
],
series: [
{
name: '营业总收入(百万元)',
type: 'line',
data: data.income,
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 },
},
{
name: '归母净利润(百万元)',
type: 'line',
yAxisIndex: 1,
data: data.profit,
smooth: true,
lineStyle: { width: 2 },
},
],
}), [data]);
return (
<ChartCard title="营业收入与净利润趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default IncomeProfitChart;

View File

@@ -0,0 +1,204 @@
/**
* 营业收入、净利润趋势与增长率分析 - 合并图表
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { IncomeProfitTrend, GrowthBars } from '../types';
interface IncomeProfitGrowthChartProps {
incomeProfitData: IncomeProfitTrend;
growthData: GrowthBars;
}
// 判断是否为预测年份(包含 E 后缀)
const isForecastYear = (year: string) => year.includes('E');
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
incomeProfitData,
growthData,
}) => {
// 找出预测数据起始索引
const forecastStartIndex = useMemo(() => {
return incomeProfitData.years.findIndex(isForecastYear);
}, [incomeProfitData.years]);
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: { color: 'rgba(212, 175, 55, 0.5)' },
},
formatter: (params: any[]) => {
if (!params || params.length === 0) return '';
const year = params[0].axisValue;
const isForecast = isForecastYear(year);
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
</div>`;
params.forEach((item: any) => {
const value = item.value;
const formattedValue = item.seriesName === '营收增长率'
? `${value?.toFixed(1) ?? '-'}%`
: `${(value / 1000)?.toFixed(1) ?? '-'}亿`;
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
${item.seriesName}
</span>
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${formattedValue}</span>
</div>`;
});
return html;
},
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['营业总收入', '归母净利润', '营收增长率'],
bottom: 0,
},
grid: {
left: 60,
right: 60,
bottom: 50,
top: 40,
containLabel: false,
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: incomeProfitData.years,
axisLabel: {
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
},
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '金额(百万元)',
position: 'left',
nameTextStyle: { color: CHART_COLORS.income },
axisLine: { lineStyle: { color: CHART_COLORS.income } },
axisLabel: {
color: CHART_COLORS.income,
formatter: (value: number) => {
if (Math.abs(value) >= 1000) {
return (value / 1000).toFixed(0) + 'k';
}
return value.toFixed(0);
},
},
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: '增长率(%)',
position: 'right',
nameTextStyle: { color: CHART_COLORS.growth },
axisLine: { lineStyle: { color: CHART_COLORS.growth } },
axisLabel: {
color: CHART_COLORS.growth,
formatter: '{value}%',
},
splitLine: { show: false },
},
],
// 预测区域背景标记
...(forecastStartIndex > 0 && {
markArea: {
silent: true,
itemStyle: {
color: THEME.forecastBg,
},
data: [[
{ xAxis: incomeProfitData.years[forecastStartIndex] },
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
]],
},
}),
series: [
{
name: '营业总收入',
type: 'bar',
data: incomeProfitData.income.map((value, idx) => ({
value,
itemStyle: {
color: isForecastYear(incomeProfitData.years[idx])
? 'rgba(212, 175, 55, 0.6)' // 预测数据半透明
: CHART_COLORS.income,
},
})),
barMaxWidth: 30,
// 预测区域标记
markArea: forecastStartIndex > 0 ? {
silent: true,
itemStyle: { color: THEME.forecastBg },
data: [[
{ xAxis: incomeProfitData.years[forecastStartIndex] },
{ xAxis: incomeProfitData.years[incomeProfitData.years.length - 1] },
]],
} : undefined,
},
{
name: '归母净利润',
type: 'line',
data: incomeProfitData.profit,
smooth: true,
lineStyle: {
width: 2,
color: CHART_COLORS.profit,
},
itemStyle: { color: CHART_COLORS.profit },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(246, 173, 85, 0.3)' },
{ offset: 1, color: 'rgba(246, 173, 85, 0.05)' },
],
},
},
},
{
name: '营收增长率',
type: 'line',
yAxisIndex: 1,
data: growthData.revenue_growth_pct,
smooth: true,
lineStyle: { width: 2, type: 'dashed', color: CHART_COLORS.growth },
itemStyle: { color: CHART_COLORS.growth },
label: {
show: true,
position: 'top',
color: THEME.textSecondary,
fontSize: 10,
formatter: (params: { value: number }) =>
params.value !== null && params.value !== undefined
? `${params.value.toFixed(1)}%`
: '',
},
},
],
}), [incomeProfitData, growthData, forecastStartIndex]);
return (
<ChartCard title="营收与利润趋势 · 增长率">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default IncomeProfitGrowthChart;

View File

@@ -0,0 +1,153 @@
/**
* PE 与 PEG 分析图
* 优化配色区分度、线条样式、Y轴颜色对应、预测区分
*/
import React, { useMemo } from 'react';
import ReactECharts from 'echarts-for-react';
import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants';
import type { PePegChartProps } from '../types';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
// 找出预测数据起始索引
const forecastStartIndex = useMemo(() => {
return data.years.findIndex(isForecastYear);
}, [data.years]);
const option = useMemo(() => ({
...BASE_CHART_CONFIG,
color: [CHART_COLORS.pe, CHART_COLORS.peg],
tooltip: {
...BASE_CHART_CONFIG.tooltip,
trigger: 'axis',
formatter: (params: any[]) => {
if (!params || params.length === 0) return '';
const year = params[0].axisValue;
const isForecast = isForecastYear(year);
let html = `<div style="font-weight:600;font-size:14px;margin-bottom:8px;color:${THEME.gold}">
${year}${isForecast ? ' <span style="font-size:11px;color:#A0AEC0">(预测)</span>' : ''}
</div>`;
params.forEach((item: any) => {
const unit = item.seriesName === 'PE' ? '倍' : '';
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
${item.seriesName}
</span>
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${item.value?.toFixed(2) ?? '-'}${unit}</span>
</div>`;
});
return html;
},
},
legend: {
...BASE_CHART_CONFIG.legend,
data: ['PE', 'PEG'],
bottom: 0,
},
xAxis: {
...BASE_CHART_CONFIG.xAxis,
type: 'category',
data: data.years,
axisLabel: {
color: (value: string) => isForecastYear(value) ? THEME.gold : THEME.textSecondary,
fontWeight: (value: string) => isForecastYear(value) ? 'bold' : 'normal',
},
},
yAxis: [
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PE(倍)',
nameTextStyle: { color: CHART_COLORS.pe },
axisLine: { lineStyle: { color: CHART_COLORS.pe } },
axisLabel: { color: CHART_COLORS.pe },
},
{
...BASE_CHART_CONFIG.yAxis,
type: 'value',
name: 'PEG',
nameTextStyle: { color: CHART_COLORS.peg },
axisLine: { lineStyle: { color: CHART_COLORS.peg } },
axisLabel: { color: CHART_COLORS.peg },
splitLine: { show: false },
},
],
series: [
{
name: 'PE',
type: 'line',
data: data.pe,
smooth: true,
lineStyle: { width: 2.5, color: CHART_COLORS.pe },
itemStyle: { color: CHART_COLORS.pe },
symbol: 'circle',
symbolSize: 6,
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(212, 175, 55, 0.2)' },
{ offset: 1, color: 'rgba(212, 175, 55, 0.02)' },
],
},
},
// 预测区域标记
markArea: forecastStartIndex > 0 ? {
silent: true,
itemStyle: { color: THEME.forecastBg },
data: [[
{ xAxis: data.years[forecastStartIndex] },
{ xAxis: data.years[data.years.length - 1] },
]],
} : undefined,
},
{
name: 'PEG',
type: 'line',
yAxisIndex: 1,
data: data.peg,
smooth: true,
lineStyle: {
width: 2.5,
type: [5, 3], // 点划线样式,区分 PE
color: CHART_COLORS.peg,
},
itemStyle: { color: CHART_COLORS.peg },
symbol: 'diamond', // 菱形符号,区分 PE
symbolSize: 6,
// PEG=1 参考线
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
color: 'rgba(255, 255, 255, 0.3)',
type: 'dashed',
},
label: {
formatter: 'PEG=1',
color: '#A0AEC0',
fontSize: 10,
},
data: [{ yAxis: 1 }],
},
},
],
}), [data, forecastStartIndex]);
return (
<ChartCard title="PE 与 PEG 分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard>
);
};
export default PePegChart;

View File

@@ -0,0 +1,11 @@
/**
* ForecastReport 子组件导出
*/
export { default as ChartCard } from './ChartCard';
export { default as IncomeProfitChart } from './IncomeProfitChart';
export { default as GrowthChart } from './GrowthChart';
export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
export { default as EpsChart } from './EpsChart';
export { default as PePegChart } from './PePegChart';
export { default as DetailTable } from './DetailTable';

View File

@@ -0,0 +1,94 @@
/**
* 盈利预测报表常量和图表配置
*/
// 黑金主题配色
export const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
bgDark: '#1A202C',
text: '#E2E8F0',
textSecondary: '#A0AEC0',
positive: '#E53E3E',
negative: '#10B981',
// 预测区域背景色
forecastBg: 'rgba(212, 175, 55, 0.08)',
};
// 图表配色方案 - 优化对比度
export const CHART_COLORS = {
income: '#D4AF37', // 收入 - 金色
profit: '#F6AD55', // 利润 - 橙金色
growth: '#10B981', // 增长率 - 翠绿色
eps: '#DAA520', // EPS - 金菊色
epsAvg: '#4A5568', // EPS行业平均 - 灰色
pe: '#D4AF37', // PE - 金色
peg: '#38B2AC', // PEG - 青色(优化对比度)
};
// ECharts 基础配置(黑金主题)
export const BASE_CHART_CONFIG = {
backgroundColor: 'transparent',
textStyle: {
color: THEME.text,
},
tooltip: {
backgroundColor: 'rgba(26, 32, 44, 0.98)',
borderColor: THEME.goldBorder,
borderWidth: 1,
padding: [12, 16],
textStyle: {
color: THEME.text,
fontSize: 13,
},
// 智能避让配置
confine: true,
appendToBody: true,
extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.3); border-radius: 6px;',
},
legend: {
textStyle: {
color: THEME.textSecondary,
},
},
grid: {
left: 50,
right: 20,
bottom: 40,
top: 40,
containLabel: false,
},
xAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
rotate: 30,
},
splitLine: {
show: false,
},
},
yAxis: {
axisLine: {
lineStyle: {
color: THEME.goldBorder,
},
},
axisLabel: {
color: THEME.textSecondary,
},
splitLine: {
lineStyle: {
color: 'rgba(212, 175, 55, 0.1)',
},
},
},
};
// 图表高度
export const CHART_HEIGHT = 280;

View File

@@ -1,161 +0,0 @@
// 简易版公司盈利预测报表视图
import React, { useState, useEffect } from 'react';
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) setData(resp.data);
} finally {
setLoading(false);
}
};
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code]);
const years = data?.detail_table?.years || [];
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
const incomeProfitOption = data ? {
color: [colors[0], colors[4]],
tooltip: { trigger: 'axis' },
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
yAxis: [
{ type: 'value', name: '收入(百万元)' },
{ type: 'value', name: '利润(百万元)' }
],
series: [
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
]
} : {};
const growthOption = data ? {
color: [colors[2]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [ {
name: '营收增长率(%)',
type: 'bar',
data: data.growth_bars.revenue_growth_pct,
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
} ]
} : {};
const epsOption = data ? {
color: [colors[3]],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
yAxis: { type: 'value', name: '元/股' },
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
} : {};
const pePegOption = data ? {
color: [colors[0], colors[1]],
tooltip: { trigger: 'axis' },
legend: { data: ['PE', 'PEG'] },
grid: { left: 40, right: 40, bottom: 40, top: 30 },
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
series: [
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
]
} : {};
return (
<Box p={4}>
<HStack align="center" justify="space-between" mb={4}>
<Heading size="md">盈利预测报表</Heading>
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
onClick={load}
isLoading={loading}
>
刷新数据
</Button>
</HStack>
{loading && !data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1,2,3,4].map(i => (
<Card key={i}>
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
<CardBody>
<Skeleton height="320px" />
</CardBody>
</Card>
))}
</SimpleGrid>
)}
{data && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
<Card><CardHeader><Heading size="sm">PE PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
</SimpleGrid>
)}
{data && (
<Card mt={4}>
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
<CardBody>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>关键指标</Th>
{years.map(y => <Th key={y}>{y}</Th>)}
</Tr>
</Thead>
<Tbody>
{data.detail_table.rows.map((row, idx) => (
<Tr key={idx}>
<Td><Tag>{row['指标']}</Tag></Td>
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,79 @@
/**
* 盈利预测报表视图 - 黑金主题
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, SimpleGrid } from '@chakra-ui/react';
import { stockService } from '@services/eventService';
import {
IncomeProfitGrowthChart,
EpsChart,
PePegChart,
DetailTable,
} from './components';
import LoadingState from '../LoadingState';
import { CHART_HEIGHT } from './constants';
import type { ForecastReportProps, ForecastData } from './types';
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');
const [data, setData] = useState<ForecastData | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!code) return;
setLoading(true);
try {
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) {
setData(resp.data);
}
} finally {
setLoading(false);
}
}, [code]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode, code]);
// 加载数据
useEffect(() => {
if (code) {
load();
}
}, [code, load]);
return (
<Box>
{/* 加载状态 */}
{loading && !data && (
<LoadingState message="加载盈利预测数据中..." height="300px" />
)}
{/* 图表区域 - 3列布局 */}
{data && (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<IncomeProfitGrowthChart
incomeProfitData={data.income_profit_trend}
growthData={data.growth_bars}
/>
<EpsChart data={data.eps_trend} />
<PePegChart data={data.pe_peg_axes} />
</SimpleGrid>
)}
{/* 详细数据表格 */}
{data && (
<Box mt={4}>
<DetailTable data={data.detail_table} />
</Box>
)}
</Box>
);
};
export default ForecastReport;

View File

@@ -0,0 +1,81 @@
/**
* 盈利预测报表类型定义
*/
// 收入利润趋势数据
export interface IncomeProfitTrend {
years: string[];
income: number[];
profit: number[];
}
// 增长率数据
export interface GrowthBars {
years: string[];
revenue_growth_pct: number[];
}
// EPS 趋势数据
export interface EpsTrend {
years: string[];
eps: number[];
}
// PE/PEG 数据
export interface PePegAxes {
years: string[];
pe: number[];
peg: number[];
}
// 详细表格行数据
export interface DetailTableRow {
指标: string;
[year: string]: string | number | null;
}
// 详细表格数据
export interface DetailTable {
years: string[];
rows: DetailTableRow[];
}
// 完整的预测报表数据
export interface ForecastData {
income_profit_trend: IncomeProfitTrend;
growth_bars: GrowthBars;
eps_trend: EpsTrend;
pe_peg_axes: PePegAxes;
detail_table: DetailTable;
}
// 组件 Props
export interface ForecastReportProps {
stockCode?: string;
}
export interface ChartCardProps {
title: string;
children: React.ReactNode;
height?: number;
}
export interface IncomeProfitChartProps {
data: IncomeProfitTrend;
}
export interface GrowthChartProps {
data: GrowthBars;
}
export interface EpsChartProps {
data: EpsTrend;
}
export interface PePegChartProps {
data: PePegAxes;
}
export interface DetailTableProps {
data: DetailTable;
}

View File

@@ -0,0 +1,44 @@
// src/views/Company/components/LoadingState.tsx
// 统一的加载状态组件 - 黑金主题
import React from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
// 黑金主题配置
const THEME = {
gold: "#D4AF37",
textSecondary: "gray.400",
};
interface LoadingStateProps {
message?: string;
height?: string;
}
/**
* 统一的加载状态组件(黑金主题)
*
* 用于所有一级 Tab 的 loading 状态展示
*/
const LoadingState: React.FC<LoadingStateProps> = ({
message = "加载中...",
height = "300px",
}) => {
return (
<Center h={height}>
<VStack spacing={4}>
<Spinner
size="xl"
color={THEME.gold}
thickness="4px"
speed="0.65s"
/>
<Text fontSize="sm" color={THEME.textSecondary}>
{message}
</Text>
</VStack>
</Center>
);
};
export default LoadingState;

View File

@@ -1,133 +0,0 @@
// src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx
// 股票概览卡片组件
import React from 'react';
import {
CardBody,
Grid,
GridItem,
VStack,
HStack,
Heading,
Badge,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
} from '@chakra-ui/react';
import ThemedCard from './ThemedCard';
import { formatNumber, formatPercent } from '../utils/formatUtils';
import type { StockSummaryCardProps } from '../types';
/**
* 股票概览卡片组件
* 显示股票基本信息、最新交易数据和融资融券数据
*/
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary, theme }) => {
if (!summary) return null;
const { latest_trade, latest_funding, latest_pledge } = summary;
return (
<ThemedCard theme={theme}>
<CardBody>
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
{/* 左侧:股票名称和涨跌 */}
<GridItem colSpan={{ base: 12, md: 4 }}>
<VStack align="start" spacing={2}>
<HStack>
<Heading size="xl" color={theme.textSecondary}>
{summary.stock_name}
</Heading>
<Badge colorScheme="blue" fontSize="lg">
{summary.stock_code}
</Badge>
</HStack>
{latest_trade && (
<HStack spacing={4}>
<Stat>
<StatNumber fontSize="4xl" color={theme.textPrimary}>
{latest_trade.close}
</StatNumber>
<StatHelpText fontSize="lg">
<StatArrow
type={latest_trade.change_percent >= 0 ? 'increase' : 'decrease'}
color={latest_trade.change_percent >= 0 ? theme.success : theme.danger}
/>
{Math.abs(latest_trade.change_percent).toFixed(2)}%
</StatHelpText>
</Stat>
</HStack>
)}
</VStack>
</GridItem>
{/* 右侧:详细指标 */}
<GridItem colSpan={{ base: 12, md: 8 }}>
{/* 交易指标 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
{latest_trade && (
<>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatNumber(latest_trade.volume, 0)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatNumber(latest_trade.amount)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{formatPercent(latest_trade.turnover_rate)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.textSecondary}>
{latest_trade.pe_ratio || '-'}
</StatNumber>
</Stat>
</>
)}
</SimpleGrid>
{/* 融资融券和质押指标 */}
{latest_funding && (
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4} mt={4}>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.success} fontSize="lg">
{formatNumber(latest_funding.financing_balance)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.danger} fontSize="lg">
{formatNumber(latest_funding.securities_balance)}
</StatNumber>
</Stat>
{latest_pledge && (
<Stat>
<StatLabel color={theme.textMuted}></StatLabel>
<StatNumber color={theme.warning} fontSize="lg">
{formatPercent(latest_pledge.pledge_ratio)}
</StatNumber>
</Stat>
)}
</SimpleGrid>
)}
</GridItem>
</Grid>
</CardBody>
</ThemedCard>
);
};
export default StockSummaryCard;

View File

@@ -0,0 +1,56 @@
// 指标卡片组件
import React from 'react';
import { Box, VStack } from '@chakra-ui/react';
import { DarkGoldCard, CardTitle, MetricValue } from './atoms';
import { darkGoldTheme } from '../../constants';
export interface MetricCardProps {
title: string;
subtitle: string;
leftIcon: React.ReactNode;
rightIcon?: React.ReactNode;
mainLabel: string;
mainValue: string;
mainColor: string;
mainSuffix?: string;
subText: React.ReactNode;
}
/**
* 指标卡片组件 - 用于展示单个指标数据
*/
const MetricCard: React.FC<MetricCardProps> = ({
title,
subtitle,
leftIcon,
rightIcon,
mainLabel,
mainValue,
mainColor,
mainSuffix,
subText,
}) => (
<DarkGoldCard>
<CardTitle
title={title}
subtitle={subtitle}
leftIcon={leftIcon}
rightIcon={rightIcon}
/>
<VStack align="start" spacing={0.5} mb={2}>
<MetricValue
label={mainLabel}
value={mainValue}
color={mainColor}
suffix={mainSuffix}
/>
</VStack>
<Box color={darkGoldTheme.textMuted} fontSize="xs">
{subText}
</Box>
</DarkGoldCard>
);
export default MetricCard;

View File

@@ -0,0 +1,90 @@
// 股票信息卡片组件4列布局版本
import React from 'react';
import { Box, HStack, Text, Icon } from '@chakra-ui/react';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { DarkGoldCard } from './atoms';
import { getTrendDescription, getPriceColor } from './utils';
import { darkGoldTheme } from '../../constants';
export interface StockHeaderCardProps {
stockName: string;
stockCode: string;
price: number;
changePercent: number;
}
/**
* 股票信息卡片 - 4 列布局中的第一个卡片
*/
const StockHeaderCard: React.FC<StockHeaderCardProps> = ({
stockName,
stockCode,
price,
changePercent,
}) => {
const isUp = changePercent >= 0;
const priceColor = getPriceColor(changePercent);
const trendDesc = getTrendDescription(changePercent);
return (
<DarkGoldCard position="relative" overflow="hidden">
{/* 背景装饰线 */}
<Box
position="absolute"
right={0}
top={0}
width="60%"
height="100%"
opacity={0.12}
background={`linear-gradient(135deg, transparent 30%, ${priceColor})`}
clipPath="polygon(40% 0, 100% 0, 100% 100%, 20% 100%)"
/>
{/* 股票名称和代码 */}
<HStack spacing={1.5} mb={2}>
<Text
color={darkGoldTheme.textPrimary}
fontSize="md"
fontWeight="bold"
>
{stockName}
</Text>
<Text color={darkGoldTheme.textMuted} fontSize="xs">
({stockCode})
</Text>
</HStack>
{/* 价格和涨跌幅 */}
<HStack spacing={2} align="baseline" mb={1.5}>
<Text
color={priceColor}
fontSize="2xl"
fontWeight="bold"
lineHeight="1"
>
{price.toFixed(2)}
</Text>
<HStack spacing={0.5} align="center">
<Icon
as={isUp ? TrendingUp : TrendingDown}
color={priceColor}
boxSize={3}
/>
<Text color={priceColor} fontSize="sm" fontWeight="bold">
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
</Text>
</HStack>
</HStack>
{/* 走势简述 */}
<Text color={darkGoldTheme.textMuted} fontSize="xs">
<Text as="span" color={priceColor} fontWeight="medium">
{trendDesc}
</Text>
</Text>
</DarkGoldCard>
);
};
export default StockHeaderCard;

View File

@@ -0,0 +1,36 @@
// 卡片标题原子组件
import React from 'react';
import { Flex, HStack, Box, Text } from '@chakra-ui/react';
import { darkGoldTheme } from '../../../constants';
interface CardTitleProps {
title: string;
subtitle: string;
leftIcon: React.ReactNode;
rightIcon?: React.ReactNode;
}
/**
* 卡片标题组件 - 显示图标+标题+副标题
*/
const CardTitle: React.FC<CardTitleProps> = ({
title,
subtitle,
leftIcon,
rightIcon,
}) => (
<Flex justify="space-between" align="center" mb={2}>
<HStack spacing={1.5}>
<Box color={darkGoldTheme.gold}>{leftIcon}</Box>
<Text color={darkGoldTheme.gold} fontSize="sm" fontWeight="bold">
{title}
</Text>
<Text color={darkGoldTheme.textMuted} fontSize="xs">
({subtitle})
</Text>
</HStack>
{rightIcon && <Box color={darkGoldTheme.gold}>{rightIcon}</Box>}
</Flex>
);
export default CardTitle;

View File

@@ -0,0 +1,42 @@
// 黑金主题卡片容器原子组件
import React from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import { darkGoldTheme } from '../../../constants';
interface DarkGoldCardProps extends BoxProps {
children: React.ReactNode;
hoverable?: boolean;
}
/**
* 黑金主题卡片容器
*/
const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
children,
hoverable = true,
...props
}) => (
<Box
bg={darkGoldTheme.bgCard}
borderRadius="lg"
border="1px solid"
borderColor={darkGoldTheme.border}
p={3}
transition="all 0.3s ease"
_hover={
hoverable
? {
bg: darkGoldTheme.bgCardHover,
borderColor: darkGoldTheme.borderHover,
transform: 'translateY(-1px)',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
}
: undefined
}
{...props}
>
{children}
</Box>
);
export default DarkGoldCard;

View File

@@ -0,0 +1,54 @@
// 核心数值展示原子组件
import React from 'react';
import { HStack, Text } from '@chakra-ui/react';
import { darkGoldTheme } from '../../../constants';
interface MetricValueProps {
label: string;
value: string;
color: string;
suffix?: string;
size?: 'sm' | 'md' | 'lg';
}
const sizeMap = {
sm: { label: 'xs', value: 'lg', suffix: 'sm' },
md: { label: 'xs', value: 'xl', suffix: 'md' },
lg: { label: 'xs', value: '2xl', suffix: 'md' },
};
/**
* 核心数值展示组件 - 显示标签+数值
*/
const MetricValue: React.FC<MetricValueProps> = ({
label,
value,
color,
suffix,
size = 'lg',
}) => {
const sizes = sizeMap[size];
return (
<HStack spacing={2} align="baseline">
<Text color={darkGoldTheme.textMuted} fontSize={sizes.label}>
{label}
</Text>
<Text
color={color}
fontSize={sizes.value}
fontWeight="bold"
lineHeight="1"
>
{value}
</Text>
{suffix && (
<Text color={color} fontSize={sizes.suffix} fontWeight="bold">
{suffix}
</Text>
)}
</HStack>
);
};
export default MetricValue;

View File

@@ -0,0 +1,56 @@
// 价格显示原子组件
import React from 'react';
import { HStack, Text, Icon } from '@chakra-ui/react';
import { TrendingUp, TrendingDown } from 'lucide-react';
interface PriceDisplayProps {
price: number;
changePercent: number;
priceColor: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const sizeMap = {
sm: { price: '2xl', percent: 'md', icon: 4 },
md: { price: '3xl', percent: 'lg', icon: 5 },
lg: { price: '4xl', percent: 'xl', icon: 6 },
xl: { price: '5xl', percent: 'xl', icon: 6 },
};
/**
* 价格显示组件 - 显示价格和涨跌幅
*/
const PriceDisplay: React.FC<PriceDisplayProps> = ({
price,
changePercent,
priceColor,
size = 'xl',
}) => {
const isUp = changePercent >= 0;
const sizes = sizeMap[size];
return (
<HStack spacing={4} align="baseline">
<Text
color={priceColor}
fontSize={sizes.price}
fontWeight="bold"
lineHeight="1"
>
{price.toFixed(2)}
</Text>
<HStack spacing={1} align="center">
<Icon
as={isUp ? TrendingUp : TrendingDown}
color={priceColor}
boxSize={sizes.icon}
/>
<Text color={priceColor} fontSize={sizes.percent} fontWeight="bold">
{isUp ? '+' : ''}{changePercent.toFixed(2)}%
</Text>
</HStack>
</HStack>
);
};
export default PriceDisplay;

View File

@@ -0,0 +1,24 @@
// 状态标签原子组件
import React from 'react';
import { Text } from '@chakra-ui/react';
interface StatusTagProps {
text: string;
color: string;
showParentheses?: boolean;
}
/**
* 状态标签 - 显示如"活跃"、"健康"等状态文字
*/
const StatusTag: React.FC<StatusTagProps> = ({
text,
color,
showParentheses = true,
}) => (
<Text color={color} fontWeight="medium" ml={1}>
{showParentheses ? `(${text})` : text}
</Text>
);
export default StatusTag;

View File

@@ -0,0 +1,6 @@
// 原子组件统一导出
export { default as StatusTag } from './StatusTag';
export { default as PriceDisplay } from './PriceDisplay';
export { default as MetricValue } from './MetricValue';
export { default as CardTitle } from './CardTitle';
export { default as DarkGoldCard } from './DarkGoldCard';

View File

@@ -0,0 +1,114 @@
// StockSummaryCard 主组件
import React from 'react';
import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react';
import { Flame, Coins, DollarSign, Shield } from 'lucide-react';
import StockHeaderCard from './StockHeaderCard';
import MetricCard from './MetricCard';
import { StatusTag } from './atoms';
import { getTurnoverStatus, getPEStatus, getPledgeStatus } from './utils';
import { formatNumber, formatPercent } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants';
import type { StockSummaryCardProps } from '../../types';
/**
* 股票概览卡片组件
* 4 列横向布局:股票信息 + 交易热度 + 估值安全 + 情绪风险
*/
const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary }) => {
if (!summary) return null;
const { latest_trade, latest_funding, latest_pledge } = summary;
// 计算状态
const turnoverStatus = latest_trade
? getTurnoverStatus(latest_trade.turnover_rate)
: { text: '-', color: darkGoldTheme.textMuted };
const peStatus = getPEStatus(latest_trade?.pe_ratio);
const pledgeStatus = latest_pledge
? getPledgeStatus(latest_pledge.pledge_ratio)
: { text: '-', color: darkGoldTheme.textMuted };
return (
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
{/* 卡片1: 股票信息 */}
{latest_trade && (
<StockHeaderCard
stockName={summary.stock_name}
stockCode={summary.stock_code}
price={latest_trade.close}
changePercent={latest_trade.change_percent}
/>
)}
{/* 卡片1: 交易热度 */}
<MetricCard
title="交易热度"
subtitle="流动性"
leftIcon={<Flame size={14} />}
rightIcon={<Coins size={14} />}
mainLabel="成交额"
mainValue={latest_trade ? formatNumber(latest_trade.amount) : '-'}
mainColor={darkGoldTheme.orange}
subText={
<HStack spacing={1} flexWrap="wrap">
<Text>
{latest_trade ? formatNumber(latest_trade.volume, 0) : '-'}
</Text>
<Text>|</Text>
<Text>
{latest_trade ? formatPercent(latest_trade.turnover_rate) : '-'}
</Text>
<StatusTag text={turnoverStatus.text} color={turnoverStatus.color} />
</HStack>
}
/>
{/* 卡片2: 估值 VS 安全 */}
<MetricCard
title="估值 VS 安全"
subtitle="便宜否"
leftIcon={<DollarSign size={14} />}
rightIcon={<Shield size={14} />}
mainLabel="市盈率(PE)"
mainValue={latest_trade?.pe_ratio?.toFixed(2) || '-'}
mainColor={darkGoldTheme.orange}
subText={
<VStack align="start" spacing={0.5}>
<Text color={peStatus.color} fontWeight="medium">
{peStatus.text}
</Text>
<HStack spacing={1} flexWrap="wrap">
<Text>
{latest_pledge ? formatPercent(latest_pledge.pledge_ratio) : '-'}
</Text>
<StatusTag text={pledgeStatus.text} color={pledgeStatus.color} />
</HStack>
</VStack>
}
/>
{/* 卡片3: 情绪与风险 */}
<MetricCard
title="情绪与风险"
subtitle="资金面"
leftIcon={<Flame size={14} />}
mainLabel="融资余额"
mainValue={latest_funding ? formatNumber(latest_funding.financing_balance) : '-'}
mainColor={darkGoldTheme.green}
subText={
<VStack align="start" spacing={0}>
<Text color={darkGoldTheme.textMuted}>()</Text>
<HStack spacing={1} flexWrap="wrap" mt={0.5}>
<Text>
{latest_funding ? formatNumber(latest_funding.securities_balance) : '-'}
</Text>
</HStack>
</VStack>
}
/>
</SimpleGrid>
);
};
export default StockSummaryCard;

View File

@@ -0,0 +1,57 @@
// 状态计算工具函数
import { darkGoldTheme } from '../../constants';
export interface StatusResult {
text: string;
color: string;
}
/**
* 获取走势简述
*/
export const getTrendDescription = (changePercent: number): string => {
if (changePercent >= 5) return '强势上涨';
if (changePercent >= 2) return '稳步上涨';
if (changePercent > 0) return '小幅上涨';
if (changePercent === 0) return '横盘整理';
if (changePercent > -2) return '小幅下跌';
if (changePercent > -5) return '震荡下跌';
return '大幅下跌';
};
/**
* 获取换手率状态标签
*/
export const getTurnoverStatus = (rate: number): StatusResult => {
if (rate >= 3) return { text: '活跃', color: darkGoldTheme.orange };
if (rate >= 1) return { text: '正常', color: darkGoldTheme.gold };
return { text: '冷清', color: darkGoldTheme.textMuted };
};
/**
* 获取市盈率估值标签
*/
export const getPEStatus = (pe: number | undefined): StatusResult => {
if (!pe || pe <= 0) return { text: '亏损', color: darkGoldTheme.red };
if (pe < 10) return { text: '极低估值 / 安全边际高', color: darkGoldTheme.green };
if (pe < 20) return { text: '合理估值', color: darkGoldTheme.gold };
if (pe < 40) return { text: '偏高估值', color: darkGoldTheme.orange };
return { text: '高估值 / 泡沫风险', color: darkGoldTheme.red };
};
/**
* 获取质押率健康状态
*/
export const getPledgeStatus = (ratio: number): StatusResult => {
if (ratio < 10) return { text: '健康', color: darkGoldTheme.green };
if (ratio < 30) return { text: '正常', color: darkGoldTheme.gold };
if (ratio < 50) return { text: '偏高', color: darkGoldTheme.orange };
return { text: '警惕', color: darkGoldTheme.red };
};
/**
* 获取价格颜色
*/
export const getPriceColor = (changePercent: number): string => {
return changePercent >= 0 ? darkGoldTheme.red : darkGoldTheme.green;
};

View File

@@ -1,5 +1,5 @@
// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx
// 大宗交易面板 - 大宗交易记录表格
// 大宗交易面板 - 黑金主题
import React from 'react';
import {
@@ -12,18 +12,15 @@ import {
Th,
Td,
TableContainer,
CardBody,
CardHeader,
Center,
Badge,
VStack,
HStack,
Tooltip,
Heading,
} from '@chakra-ui/react';
import ThemedCard from '../ThemedCard';
import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants';
import type { Theme, BigDealData } from '../../types';
export interface BigDealPanelProps {
@@ -31,69 +28,116 @@ export interface BigDealPanelProps {
bigDealData: BigDealData;
}
const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
// 黑金徽章样式
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'gold' | 'orange' | 'green' | 'purple' }> = ({
children,
variant = 'gold',
}) => {
const colors = {
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
};
const style = colors[variant];
return (
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.textSecondary}>
<Box
px={2}
py={1}
bg={style.bg}
color={style.color}
borderRadius="md"
fontSize="xs"
fontWeight="medium"
>
{children}
</Box>
);
};
const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
return (
<Box {...darkGoldCardStyle} overflow="hidden">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</CardHeader>
<CardBody>
</Box>
<Box p={4}>
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
<VStack spacing={6} align="stretch">
<VStack spacing={4} align="stretch">
{bigDealData.daily_stats.map((dayStats, idx) => (
<Box
key={idx}
p={4}
bg={theme.bgDark}
bg="rgba(212, 175, 55, 0.05)"
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
borderColor="rgba(212, 175, 55, 0.15)"
>
<HStack justify="space-between" mb={4}>
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
{dayStats.date}
</Text>
<HStack spacing={4}>
<Badge colorScheme="blue" fontSize="md">
<HStack spacing={2} flexWrap="wrap">
<DarkGoldBadge variant="gold">
: {dayStats.count}
</Badge>
<Badge colorScheme="green" fontSize="md">
</DarkGoldBadge>
<DarkGoldBadge variant="green">
: {formatNumber(dayStats.total_volume)}
</Badge>
<Badge colorScheme="orange" fontSize="md">
</DarkGoldBadge>
<DarkGoldBadge variant="orange">
: {formatNumber(dayStats.total_amount)}
</Badge>
<Badge colorScheme="purple" fontSize="md">
</DarkGoldBadge>
<DarkGoldBadge variant="purple">
: {dayStats.avg_price?.toFixed(2) || '-'}
</Badge>
</DarkGoldBadge>
</HStack>
</HStack>
{dayStats.deals && dayStats.deals.length > 0 && (
<TableContainer>
<Table variant="simple" size="sm">
<Table variant="unstyled" size="sm">
<Thead>
<Tr>
<Th color={theme.textSecondary}></Th>
<Th color={theme.textSecondary}></Th>
<Th isNumeric color={theme.textSecondary}>
<Tr borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Th color={darkGoldTheme.textMuted} fontWeight="medium"></Th>
<Th color={darkGoldTheme.textMuted} fontWeight="medium"></Th>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
</Tr>
</Thead>
<Tbody>
{dayStats.deals.map((deal, i) => (
<Tr key={i} _hover={{ bg: 'rgba(43, 108, 176, 0.05)' }}>
<Tr
key={i}
_hover={{ bg: 'rgba(212, 175, 55, 0.08)' }}
borderBottom="1px solid"
borderColor="rgba(212, 175, 55, 0.1)"
>
<Td
color={theme.textPrimary}
color={darkGoldTheme.textSecondary}
fontSize="xs"
maxW="200px"
isTruncated
@@ -103,7 +147,7 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
</Tooltip>
</Td>
<Td
color={theme.textPrimary}
color={darkGoldTheme.textSecondary}
fontSize="xs"
maxW="200px"
isTruncated
@@ -112,13 +156,13 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
<Text>{deal.seller_dept || '-'}</Text>
</Tooltip>
</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
<Td isNumeric color={darkGoldTheme.gold} fontWeight="bold">
{deal.price?.toFixed(2) || '-'}
</Td>
<Td isNumeric color={theme.textPrimary}>
<Td isNumeric color={darkGoldTheme.textSecondary}>
{deal.volume?.toFixed(2) || '-'}
</Td>
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
<Td isNumeric color={darkGoldTheme.orange} fontWeight="bold">
{deal.amount?.toFixed(2) || '-'}
</Td>
</Tr>
@@ -132,11 +176,11 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ theme, bigDealData }) => {
</VStack>
) : (
<Center h="200px">
<Text color={theme.textMuted}></Text>
<Text color={darkGoldTheme.textMuted}></Text>
</Center>
)}
</CardBody>
</ThemedCard>
</Box>
</Box>
);
};

View File

@@ -1,12 +1,10 @@
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
// 融资融券面板 - 融资融券数据图表和卡片
// 融资融券面板 - 黑金主题
import React from 'react';
import {
Box,
Text,
CardBody,
CardHeader,
VStack,
HStack,
Grid,
@@ -14,9 +12,9 @@ import {
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import ThemedCard from '../ThemedCard';
import { formatNumber } from '../../utils/formatUtils';
import { getFundingOption } from '../../utils/chartOptions';
import { getFundingDarkGoldOption } from '../../utils/chartOptions';
import { darkGoldTheme } from '../../constants';
import type { Theme, FundingDayData } from '../../types';
export interface FundingPanelProps {
@@ -24,45 +22,73 @@ export interface FundingPanelProps {
fundingData: FundingDayData[];
}
const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
transform: 'translateY(-2px)',
},
};
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
return (
<VStack spacing={6} align="stretch">
<ThemedCard theme={theme}>
<CardBody>
{fundingData.length > 0 && (
<Box h="400px">
<ReactECharts
option={getFundingOption(theme, fundingData)}
style={{ height: '100%', width: '100%' }}
theme="light"
/>
</Box>
)}
</CardBody>
</ThemedCard>
{/* 图表卡片 */}
<Box {...darkGoldCardStyle} p={6}>
{fundingData.length > 0 && (
<Box h="400px">
<ReactECharts
option={getFundingDarkGoldOption(fundingData)}
style={{ height: '100%', width: '100%' }}
theme="dark"
/>
</Box>
)}
</Box>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
{/* 融资数据 */}
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.success}>
<Box {...darkGoldCardStyle} overflow="hidden">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</CardHeader>
<CardBody>
</Box>
<Box p={4}>
<VStack spacing={3} align="stretch">
{fundingData
.slice(-5)
.reverse()
.map((item, idx) => (
<Box key={idx} p={3} bg="rgba(255, 68, 68, 0.05)" borderRadius="md">
<Box
key={idx}
p={3}
bg="rgba(212, 175, 55, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(212, 175, 55, 0.12)',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
>
<HStack justify="space-between">
<Text color={theme.textMuted}>{item.date}</Text>
<Text color={darkGoldTheme.textMuted} fontSize="sm">
{item.date}
</Text>
<VStack align="end" spacing={0}>
<Text color={theme.textPrimary} fontWeight="bold">
<Text color={darkGoldTheme.gold} fontWeight="bold">
{formatNumber(item.financing.balance)}
</Text>
<Text fontSize="xs" color={theme.textMuted}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{formatNumber(item.financing.buy)} /
{formatNumber(item.financing.repay)}
</Text>
@@ -71,30 +97,44 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
</Box>
))}
</VStack>
</CardBody>
</ThemedCard>
</Box>
</Box>
{/* 融券数据 */}
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.danger}>
<Box {...darkGoldCardStyle} overflow="hidden">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.orange}>
</Heading>
</CardHeader>
<CardBody>
</Box>
<Box p={4}>
<VStack spacing={3} align="stretch">
{fundingData
.slice(-5)
.reverse()
.map((item, idx) => (
<Box key={idx} p={3} bg="rgba(0, 200, 81, 0.05)" borderRadius="md">
<Box
key={idx}
p={3}
bg="rgba(255, 149, 0, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 149, 0, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 149, 0, 0.12)',
borderColor: 'rgba(255, 149, 0, 0.3)',
}}
>
<HStack justify="space-between">
<Text color={theme.textMuted}>{item.date}</Text>
<Text color={darkGoldTheme.textMuted} fontSize="sm">
{item.date}
</Text>
<VStack align="end" spacing={0}>
<Text color={theme.textPrimary} fontWeight="bold">
<Text color={darkGoldTheme.orange} fontWeight="bold">
{formatNumber(item.securities.balance)}
</Text>
<Text fontSize="xs" color={theme.textMuted}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{formatNumber(item.securities.sell)} /
{formatNumber(item.securities.repay)}
</Text>
@@ -103,8 +143,8 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ theme, fundingData }) => {
</Box>
))}
</VStack>
</CardBody>
</ThemedCard>
</Box>
</Box>
</Grid>
</VStack>
);

View File

@@ -1,5 +1,5 @@
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
// 股权质押面板 - 质押图表和表格
// 股权质押面板 - 黑金主题
import React from 'react';
import {
@@ -12,16 +12,14 @@ import {
Th,
Td,
TableContainer,
CardBody,
CardHeader,
VStack,
Heading,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import ThemedCard from '../ThemedCard';
import { formatNumber, formatPercent } from '../../utils/formatUtils';
import { getPledgeOption } from '../../utils/chartOptions';
import { getPledgeDarkGoldOption } from '../../utils/chartOptions';
import { darkGoldTheme } from '../../constants';
import type { Theme, PledgeData } from '../../types';
export interface PledgePanelProps {
@@ -29,51 +27,65 @@ export interface PledgePanelProps {
pledgeData: PledgeData[];
}
const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
return (
<VStack spacing={6} align="stretch">
<ThemedCard theme={theme}>
<CardBody>
{pledgeData.length > 0 && (
<Box h="400px">
<ReactECharts
option={getPledgeOption(theme, pledgeData)}
style={{ height: '100%', width: '100%' }}
theme="light"
/>
</Box>
)}
</CardBody>
</ThemedCard>
{/* 图表卡片 */}
<Box {...darkGoldCardStyle} p={6}>
{pledgeData.length > 0 && (
<Box h="400px">
<ReactECharts
option={getPledgeDarkGoldOption(pledgeData)}
style={{ height: '100%', width: '100%' }}
theme="dark"
/>
</Box>
)}
</Box>
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.textSecondary}>
{/* 质押明细表格 */}
<Box {...darkGoldCardStyle} overflow="hidden">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</CardHeader>
<CardBody>
</Box>
<Box p={4}>
<TableContainer>
<Table variant="simple" size="sm">
<Table variant="unstyled" size="sm">
<Thead>
<Tr>
<Th color={theme.textSecondary}></Th>
<Th isNumeric color={theme.textSecondary}>
<Tr borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Th color={darkGoldTheme.textMuted} fontWeight="medium"></Th>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
()
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
</Th>
<Th isNumeric color={theme.textSecondary}>
<Th isNumeric color={darkGoldTheme.textMuted} fontWeight="medium">
</Th>
</Tr>
@@ -81,24 +93,29 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
<Tbody>
{pledgeData.length > 0 ? (
pledgeData.map((item, idx) => (
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
<Td color={theme.textPrimary}>{item.end_date}</Td>
<Td isNumeric color={theme.textPrimary}>
<Tr
key={idx}
_hover={{ bg: 'rgba(212, 175, 55, 0.08)' }}
borderBottom="1px solid"
borderColor="rgba(212, 175, 55, 0.1)"
>
<Td color={darkGoldTheme.textSecondary}>{item.end_date}</Td>
<Td isNumeric color={darkGoldTheme.textSecondary}>
{formatNumber(item.unrestricted_pledge, 0)}
</Td>
<Td isNumeric color={theme.textPrimary}>
<Td isNumeric color={darkGoldTheme.textSecondary}>
{formatNumber(item.restricted_pledge, 0)}
</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
<Td isNumeric color={darkGoldTheme.gold} fontWeight="bold">
{formatNumber(item.total_pledge, 0)}
</Td>
<Td isNumeric color={theme.textPrimary}>
<Td isNumeric color={darkGoldTheme.textSecondary}>
{formatNumber(item.total_shares, 0)}
</Td>
<Td isNumeric color={theme.warning} fontWeight="bold">
<Td isNumeric color={darkGoldTheme.orange} fontWeight="bold">
{formatPercent(item.pledge_ratio)}
</Td>
<Td isNumeric color={theme.textPrimary}>
<Td isNumeric color={darkGoldTheme.textSecondary}>
{item.pledge_count}
</Td>
</Tr>
@@ -106,7 +123,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
) : (
<Tr>
<Td colSpan={7} textAlign="center" py={8}>
<Text fontSize="sm" color={theme.textMuted}>
<Text fontSize="sm" color={darkGoldTheme.textMuted}>
</Text>
</Td>
@@ -115,8 +132,8 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ theme, pledgeData }) => {
</Tbody>
</Table>
</TableContainer>
</CardBody>
</ThemedCard>
</Box>
</Box>
</VStack>
);
};

View File

@@ -1,381 +0,0 @@
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel.tsx
// 交易数据面板 - K线图、分钟图、交易明细表格
import React from 'react';
import {
Box,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
CardBody,
CardHeader,
Spinner,
Center,
Badge,
VStack,
HStack,
Button,
Grid,
Icon,
Heading,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
ChevronUpIcon,
InfoIcon,
RepeatIcon,
TimeIcon,
ArrowUpIcon,
ArrowDownIcon,
} from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import ThemedCard from '../ThemedCard';
import { formatNumber, formatPercent } from '../../utils/formatUtils';
import { getKLineOption, getMinuteKLineOption } from '../../utils/chartOptions';
import type { Theme, TradeDayData, MinuteData, RiseAnalysis } from '../../types';
export interface TradeDataPanelProps {
theme: Theme;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
onLoadMinuteData: () => void;
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
}
const TradeDataPanel: React.FC<TradeDataPanelProps> = ({
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
onLoadMinuteData,
onChartClick,
}) => {
return (
<VStack spacing={6} align="stretch">
{/* K线图 */}
<ThemedCard theme={theme}>
<CardBody>
{tradeData.length > 0 && (
<Box h="600px">
<ReactECharts
option={getKLineOption(theme, tradeData, analysisMap)}
style={{ height: '100%', width: '100%' }}
theme="light"
onEvents={{ click: onChartClick }}
/>
</Box>
)}
</CardBody>
</ThemedCard>
{/* 分钟K线数据 */}
<ThemedCard theme={theme}>
<CardHeader>
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Icon as={TimeIcon} color={theme.primary} boxSize={5} />
<Heading size="md" color={theme.textSecondary}>
</Heading>
{minuteData && minuteData.trade_date && (
<Badge colorScheme="blue" fontSize="xs">
{minuteData.trade_date}
</Badge>
)}
</HStack>
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
colorScheme="blue"
onClick={onLoadMinuteData}
isLoading={minuteLoading}
loadingText="获取中"
>
</Button>
</HStack>
</CardHeader>
<CardBody>
{minuteLoading ? (
<Center h="400px">
<VStack spacing={4}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={theme.bgDark}
color={theme.primary}
size="lg"
/>
<Text color={theme.textMuted} fontSize="sm">
...
</Text>
</VStack>
</Center>
) : minuteData && minuteData.data && minuteData.data.length > 0 ? (
<VStack spacing={6} align="stretch">
<Box h="500px">
<ReactECharts
option={getMinuteKLineOption(theme, minuteData)}
style={{ height: '100%', width: '100%' }}
theme="light"
/>
</Box>
{/* 分钟数据统计 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
<Stat>
<StatLabel color={theme.textMuted} fontSize="sm">
<HStack spacing={1}>
<Icon as={ArrowUpIcon} boxSize={3} />
<Text></Text>
</HStack>
</StatLabel>
<StatNumber color={theme.textPrimary} fontSize="lg">
{minuteData.data[0]?.open?.toFixed(2) || '-'}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted} fontSize="sm">
<HStack spacing={1}>
<Icon as={ArrowDownIcon} boxSize={3} />
<Text></Text>
</HStack>
</StatLabel>
<StatNumber
color={
(minuteData.data[minuteData.data.length - 1]?.close || 0) >=
(minuteData.data[0]?.open || 0)
? theme.success
: theme.danger
}
fontSize="lg"
>
{minuteData.data[minuteData.data.length - 1]?.close?.toFixed(2) || '-'}
</StatNumber>
<StatHelpText fontSize="xs">
<StatArrow
type={
(minuteData.data[minuteData.data.length - 1]?.close || 0) >=
(minuteData.data[0]?.open || 0)
? 'increase'
: 'decrease'
}
/>
{(() => {
const lastClose = minuteData.data[minuteData.data.length - 1]?.close;
const firstOpen = minuteData.data[0]?.open;
if (lastClose && firstOpen) {
return Math.abs(((lastClose - firstOpen) / firstOpen) * 100).toFixed(2);
}
return '0.00';
})()}
%
</StatHelpText>
</Stat>
<Stat>
<StatLabel color={theme.textMuted} fontSize="sm">
<HStack spacing={1}>
<Icon as={ChevronUpIcon} boxSize={3} />
<Text></Text>
</HStack>
</StatLabel>
<StatNumber color={theme.success} fontSize="lg">
{Math.max(...minuteData.data.map((item) => item.high).filter(Boolean)).toFixed(
2
)}
</StatNumber>
</Stat>
<Stat>
<StatLabel color={theme.textMuted} fontSize="sm">
<HStack spacing={1}>
<Icon as={ChevronDownIcon} boxSize={3} />
<Text></Text>
</HStack>
</StatLabel>
<StatNumber color={theme.danger} fontSize="lg">
{Math.min(...minuteData.data.map((item) => item.low).filter(Boolean)).toFixed(2)}
</StatNumber>
</Stat>
</SimpleGrid>
{/* 成交数据分析 */}
<Box
p={4}
bg={theme.bgDark}
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" color={theme.textSecondary}>
</Text>
<HStack spacing={2}>
<Badge colorScheme="purple" fontSize="xs">
:{' '}
{formatNumber(
minuteData.data.reduce((sum, item) => sum + item.volume, 0),
0
)}
</Badge>
<Badge colorScheme="orange" fontSize="xs">
:{' '}
{formatNumber(minuteData.data.reduce((sum, item) => sum + item.amount, 0))}
</Badge>
</HStack>
</HStack>
<Grid templateColumns="repeat(3, 1fr)" gap={4}>
<Box>
<Text fontSize="sm" color={theme.textMuted} mb={2}>
</Text>
<Text fontSize="sm" color={theme.textPrimary}>
{(() => {
const maxVolume = Math.max(...minuteData.data.map((item) => item.volume));
const activeTime = minuteData.data.find(
(item) => item.volume === maxVolume
);
return activeTime
? `${activeTime.time} (${formatNumber(maxVolume, 0)})`
: '-';
})()}
</Text>
</Box>
<Box>
<Text fontSize="sm" color={theme.textMuted} mb={2}>
</Text>
<Text fontSize="sm" color={theme.textPrimary}>
{(
minuteData.data.reduce((sum, item) => sum + item.close, 0) /
minuteData.data.length
).toFixed(2)}
</Text>
</Box>
<Box>
<Text fontSize="sm" color={theme.textMuted} mb={2}>
</Text>
<Text fontSize="sm" color={theme.textPrimary}>
{minuteData.data.length}
</Text>
</Box>
</Grid>
</Box>
</VStack>
) : (
<Center h="300px">
<VStack spacing={4}>
<Icon as={InfoIcon} color={theme.textMuted} boxSize={12} />
<VStack spacing={2}>
<Text color={theme.textMuted} fontSize="lg">
</Text>
<Text color={theme.textMuted} fontSize="sm" textAlign="center">
"获取分钟数据"
</Text>
</VStack>
</VStack>
</Center>
)}
</CardBody>
</ThemedCard>
{/* 交易明细表格 */}
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.textSecondary}>
</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th color={theme.textSecondary}></Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
<Th isNumeric color={theme.textSecondary}>
</Th>
</Tr>
</Thead>
<Tbody>
{tradeData
.slice(-10)
.reverse()
.map((item, idx) => (
<Tr key={idx} _hover={{ bg: theme.bgDark }}>
<Td color={theme.textPrimary}>{item.date}</Td>
<Td isNumeric color={theme.textPrimary}>
{item.open}
</Td>
<Td isNumeric color={theme.textPrimary}>
{item.high}
</Td>
<Td isNumeric color={theme.textPrimary}>
{item.low}
</Td>
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
{item.close}
</Td>
<Td
isNumeric
color={item.change_percent >= 0 ? theme.success : theme.danger}
fontWeight="bold"
>
{item.change_percent >= 0 ? '+' : ''}
{formatPercent(item.change_percent)}
</Td>
<Td isNumeric color={theme.textPrimary}>
{formatNumber(item.volume, 0)}
</Td>
<Td isNumeric color={theme.textPrimary}>
{formatNumber(item.amount)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</ThemedCard>
</VStack>
);
};
export default TradeDataPanel;

View File

@@ -0,0 +1,242 @@
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx
// K线模块 - 日K线/分钟K线切换展示黑金主题
import React, { useState } from 'react';
import {
Box,
Text,
VStack,
HStack,
Button,
ButtonGroup,
Badge,
Center,
Spinner,
Icon,
Select,
} from '@chakra-ui/react';
import { RepeatIcon, InfoIcon } from '@chakra-ui/icons';
import { BarChart2, Clock, TrendingUp, Calendar } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants';
import { getKLineDarkGoldOption, getMinuteKLineDarkGoldOption } from '../../../utils/chartOptions';
import type { KLineModuleProps } from '../../../types';
// 空状态组件(内联)
const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => (
<Center h="300px">
<VStack spacing={4}>
<Icon as={InfoIcon} color={darkGoldTheme.textMuted} boxSize={12} />
<VStack spacing={2}>
<Text color={darkGoldTheme.textMuted} fontSize="lg">{title}</Text>
<Text color={darkGoldTheme.textMuted} fontSize="sm" textAlign="center">{description}</Text>
</VStack>
</VStack>
</Center>
);
// 重新导出类型供外部使用
export type { KLineModuleProps } from '../../../types';
type ChartMode = 'daily' | 'minute';
const KLineModule: React.FC<KLineModuleProps> = ({
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
onLoadMinuteData,
onChartClick,
selectedPeriod,
onPeriodChange,
}) => {
const [mode, setMode] = useState<ChartMode>('daily');
const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0;
// 切换到分钟模式时自动加载数据
const handleModeChange = (newMode: ChartMode) => {
setMode(newMode);
if (newMode === 'minute' && !hasMinuteData && !minuteLoading) {
onLoadMinuteData();
}
};
// 黑金主题按钮样式
const activeButtonStyle = {
bg: `linear-gradient(135deg, ${darkGoldTheme.gold} 0%, ${darkGoldTheme.orange} 100%)`,
color: '#1a1a2e',
borderColor: darkGoldTheme.gold,
_hover: {
bg: `linear-gradient(135deg, ${darkGoldTheme.goldLight} 0%, ${darkGoldTheme.gold} 100%)`,
},
};
const inactiveButtonStyle = {
bg: 'transparent',
color: darkGoldTheme.textMuted,
borderColor: darkGoldTheme.border,
_hover: {
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: darkGoldTheme.gold,
color: darkGoldTheme.gold,
},
};
return (
<Box
bg="transparent"
overflow="hidden"
>
{/* 卡片头部 */}
<Box py={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Box
p={2}
borderRadius="lg"
bg={darkGoldTheme.tagBg}
>
<TrendingUp size={20} color={darkGoldTheme.gold} />
</Box>
<Text
fontSize="lg"
fontWeight="bold"
bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`}
bgClip="text"
>
{mode === 'daily' ? '日K线图' : '分钟K线图'}
</Text>
{mode === 'minute' && minuteData?.trade_date && (
<Badge
bg={darkGoldTheme.tagBg}
color={darkGoldTheme.gold}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{minuteData.trade_date}
</Badge>
)}
</HStack>
<HStack spacing={3}>
{/* 日K模式下显示时间范围选择器 */}
{mode === 'daily' && onPeriodChange && (
<HStack spacing={2}>
<Icon as={Calendar} boxSize={4} color={darkGoldTheme.textMuted} />
<Select
size="sm"
value={selectedPeriod}
onChange={(e) => onPeriodChange(Number(e.target.value))}
bg="transparent"
borderColor={darkGoldTheme.border}
color={darkGoldTheme.textPrimary}
maxW="100px"
_hover={{ borderColor: darkGoldTheme.gold }}
_focus={{ borderColor: darkGoldTheme.gold, boxShadow: 'none' }}
sx={{
option: {
background: '#1a1a2e',
color: darkGoldTheme.textPrimary,
},
}}
>
{PERIOD_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
)}
{/* 分钟模式下的刷新按钮 */}
{mode === 'minute' && (
<Button
leftIcon={<RepeatIcon />}
size="sm"
variant="outline"
onClick={onLoadMinuteData}
isLoading={minuteLoading}
loadingText="获取中"
{...inactiveButtonStyle}
>
</Button>
)}
{/* 模式切换按钮组 */}
<ButtonGroup size="sm" isAttached>
<Button
leftIcon={<BarChart2 size={14} />}
onClick={() => handleModeChange('daily')}
{...(mode === 'daily' ? activeButtonStyle : inactiveButtonStyle)}
>
K
</Button>
<Button
leftIcon={<Clock size={14} />}
onClick={() => handleModeChange('minute')}
{...(mode === 'minute' ? activeButtonStyle : inactiveButtonStyle)}
>
</Button>
</ButtonGroup>
</HStack>
</HStack>
</Box>
{/* 卡片内容 */}
<Box pt={4}>
{mode === 'daily' ? (
// 日K线图
tradeData.length > 0 ? (
<Box h="600px">
<ReactECharts
option={getKLineDarkGoldOption(tradeData, analysisMap)}
style={{ height: '100%', width: '100%' }}
theme="dark"
onEvents={{ click: onChartClick }}
/>
</Box>
) : (
<EmptyState title="暂无日K线数据" description="该股票暂无交易数据" />
)
) : (
// 分钟K线图
minuteLoading ? (
<Center h="500px">
<VStack spacing={4}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="rgba(212, 175, 55, 0.2)"
color={darkGoldTheme.gold}
size="lg"
/>
<Text color={darkGoldTheme.textMuted} fontSize="sm">
...
</Text>
</VStack>
</Center>
) : hasMinuteData ? (
<Box h="500px">
<ReactECharts
option={getMinuteKLineDarkGoldOption(minuteData)}
style={{ height: '100%', width: '100%' }}
theme="dark"
/>
</Box>
) : (
<EmptyState title="暂无分钟数据" description="点击刷新按钮获取当日分钟频数据" />
)
)}
</Box>
</Box>
);
};
export default KLineModule;

View File

@@ -0,0 +1,51 @@
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/index.tsx
// 交易数据面板 - K线模块日K/分钟切换)
import React from 'react';
import KLineModule from './KLineModule';
import type { Theme, TradeDayData, MinuteData, RiseAnalysis } from '../../../types';
export interface TradeDataPanelProps {
theme: Theme;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
onLoadMinuteData: () => void;
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
selectedPeriod?: number;
onPeriodChange?: (period: number) => void;
}
const TradeDataPanel: React.FC<TradeDataPanelProps> = ({
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
onLoadMinuteData,
onChartClick,
selectedPeriod,
onPeriodChange,
}) => {
return (
<KLineModule
theme={theme}
tradeData={tradeData}
minuteData={minuteData}
minuteLoading={minuteLoading}
analysisMap={analysisMap}
onLoadMinuteData={onLoadMinuteData}
onChartClick={onChartClick}
selectedPeriod={selectedPeriod}
onPeriodChange={onPeriodChange}
/>
);
};
export default TradeDataPanel;
// 导出子组件供外部按需使用
export { default as KLineModule } from './KLineModule';
export type { KLineModuleProps } from './KLineModule';

View File

@@ -1,22 +1,19 @@
// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx
// 龙虎榜面板 - 龙虎榜数据展示
// 龙虎榜面板 - 黑金主题
import React from 'react';
import {
Box,
Text,
CardBody,
CardHeader,
Center,
Badge,
VStack,
HStack,
Grid,
Heading,
} from '@chakra-ui/react';
import ThemedCard from '../ThemedCard';
import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants';
import type { Theme, UnusualData } from '../../types';
export interface UnusualPanelProps {
@@ -24,49 +21,87 @@ export interface UnusualPanelProps {
unusualData: UnusualData;
}
const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
// 黑金徽章样式
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'red' | 'green' | 'gold' }> = ({
children,
variant = 'gold',
}) => {
const colors = {
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
};
const style = colors[variant];
return (
<ThemedCard theme={theme}>
<CardHeader>
<Heading size="md" color={theme.textSecondary}>
<Box
px={2}
py={1}
bg={style.bg}
color={style.color}
borderRadius="md"
fontSize="xs"
fontWeight="medium"
>
{children}
</Box>
);
};
const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
return (
<Box {...darkGoldCardStyle} overflow="hidden">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</CardHeader>
<CardBody>
</Box>
<Box p={4}>
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
<VStack spacing={6} align="stretch">
<VStack spacing={4} align="stretch">
{unusualData.grouped_data.map((dayData, idx) => (
<Box
key={idx}
p={4}
bg={theme.bgDark}
bg="rgba(212, 175, 55, 0.05)"
borderRadius="lg"
border="1px solid"
borderColor={theme.border}
borderColor="rgba(212, 175, 55, 0.15)"
>
<HStack justify="space-between" mb={4}>
<Text fontSize="lg" fontWeight="bold" color={theme.textSecondary}>
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
{dayData.date}
</Text>
<HStack spacing={4}>
<Badge colorScheme="red" fontSize="md">
<HStack spacing={2} flexWrap="wrap">
<DarkGoldBadge variant="red">
: {formatNumber(dayData.total_buy)}
</Badge>
<Badge colorScheme="green" fontSize="md">
</DarkGoldBadge>
<DarkGoldBadge variant="green">
: {formatNumber(dayData.total_sell)}
</Badge>
<Badge
colorScheme={dayData.net_amount > 0 ? 'red' : 'green'}
fontSize="md"
>
</DarkGoldBadge>
<DarkGoldBadge variant={dayData.net_amount > 0 ? 'red' : 'green'}>
: {formatNumber(dayData.net_amount)}
</Badge>
</DarkGoldBadge>
</HStack>
</HStack>
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<Box>
<Text fontWeight="bold" color={theme.success} mb={2}>
<Text fontWeight="bold" color={darkGoldTheme.red} mb={2} fontSize="sm">
</Text>
<VStack spacing={1} align="stretch">
@@ -76,24 +111,31 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
key={i}
justify="space-between"
p={2}
bg="rgba(255, 68, 68, 0.05)"
bg="rgba(255, 68, 68, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 68, 68, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 68, 68, 0.12)',
borderColor: 'rgba(255, 68, 68, 0.3)',
}}
>
<Text
fontSize="sm"
color={theme.textPrimary}
fontSize="xs"
color={darkGoldTheme.textSecondary}
isTruncated
maxW="70%"
>
{buyer.dept_name}
</Text>
<Text fontSize="sm" color={theme.success} fontWeight="bold">
<Text fontSize="xs" color={darkGoldTheme.red} fontWeight="bold">
{formatNumber(buyer.buy_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="sm" color={theme.textMuted}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
)}
@@ -101,7 +143,7 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
</Box>
<Box>
<Text fontWeight="bold" color={theme.danger} mb={2}>
<Text fontWeight="bold" color={darkGoldTheme.green} mb={2} fontSize="sm">
</Text>
<VStack spacing={1} align="stretch">
@@ -111,24 +153,31 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
key={i}
justify="space-between"
p={2}
bg="rgba(0, 200, 81, 0.05)"
bg="rgba(0, 200, 81, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(0, 200, 81, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(0, 200, 81, 0.12)',
borderColor: 'rgba(0, 200, 81, 0.3)',
}}
>
<Text
fontSize="sm"
color={theme.textPrimary}
fontSize="xs"
color={darkGoldTheme.textSecondary}
isTruncated
maxW="70%"
>
{seller.dept_name}
</Text>
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
<Text fontSize="xs" color={darkGoldTheme.green} fontWeight="bold">
{formatNumber(seller.sell_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="sm" color={theme.textMuted}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
)}
@@ -137,14 +186,22 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
</Grid>
{/* 信息类型标签 */}
<HStack mt={3} spacing={2}>
<Text fontSize="sm" color={theme.textMuted}>
<HStack mt={3} spacing={2} flexWrap="wrap">
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
:
</Text>
{dayData.info_types?.map((type, i) => (
<Badge key={i} colorScheme="blue" fontSize="xs">
<Box
key={i}
px={2}
py={0.5}
bg="rgba(212, 175, 55, 0.1)"
color={darkGoldTheme.gold}
borderRadius="sm"
fontSize="xs"
>
{type}
</Badge>
</Box>
))}
</HStack>
</Box>
@@ -152,11 +209,11 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ theme, unusualData }) => {
</VStack>
) : (
<Center h="200px">
<Text color={theme.textMuted}></Text>
<Text color={darkGoldTheme.textMuted}></Text>
</Center>
)}
</CardBody>
</ThemedCard>
</Box>
</Box>
);
};

View File

@@ -13,3 +13,7 @@ export type { FundingPanelProps } from './FundingPanel';
export type { BigDealPanelProps } from './BigDealPanel';
export type { UnusualPanelProps } from './UnusualPanel';
export type { PledgePanelProps } from './PledgePanel';
// 导出 TradeDataPanel 子组件
export { KLineModule } from './TradeDataPanel';
export type { KLineModuleProps } from './TradeDataPanel';

View File

@@ -28,6 +28,35 @@ export const themes: Record<'light', Theme> = {
},
};
/**
* 黑金主题配置 - 用于 StockSummaryCard
*/
export const darkGoldTheme = {
// 背景
bgCard: 'linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%)',
bgCardHover: 'linear-gradient(135deg, #252540 0%, #1a1a2e 100%)',
// 边框
border: 'rgba(212, 175, 55, 0.3)',
borderHover: 'rgba(212, 175, 55, 0.6)',
// 文字
textPrimary: '#FFFFFF',
textSecondary: 'rgba(255, 255, 255, 0.85)',
textMuted: 'rgba(255, 255, 255, 0.6)',
// 强调色
gold: '#D4AF37',
goldLight: '#F4D03F',
orange: '#FF9500',
green: '#00C851',
red: '#FF4444',
// 标签背景
tagBg: 'rgba(212, 175, 55, 0.15)',
tagText: '#D4AF37',
};
/**
* 默认股票代码
*/

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/hooks/useMarketData.ts
// MarketDataView 数据获取 Hook
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { logger } from '@utils/logger';
import { marketService } from '../services/marketService';
import { DEFAULT_PERIOD } from '../constants';
@@ -28,6 +28,7 @@ export const useMarketData = (
): UseMarketDataReturn => {
// 主数据状态
const [loading, setLoading] = useState(false);
const [tradeLoading, setTradeLoading] = useState(false);
const [summary, setSummary] = useState<MarketSummary | null>(null);
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
@@ -40,6 +41,13 @@ export const useMarketData = (
const [minuteData, setMinuteData] = useState<MinuteData | null>(null);
const [minuteLoading, setMinuteLoading] = useState(false);
// 记录是否已完成首次加载
const isInitializedRef = useRef(false);
// 记录上一次的 stockCode用于判断是否需要重新加载所有数据
const prevStockCodeRef = useRef(stockCode);
// 记录上一次的 period用于判断是否需要刷新交易数据
const prevPeriodRef = useRef(period);
/**
* 加载所有市场数据
*/
@@ -159,6 +167,50 @@ export const useMarketData = (
}
}, [stockCode]);
/**
* 单独刷新日K线数据只刷新交易数据和涨幅分析
* 用于切换时间周期时,避免重新加载所有数据
*/
const refreshTradeData = useCallback(async () => {
if (!stockCode) return;
logger.debug('useMarketData', '刷新日K线数据', { stockCode, period });
setTradeLoading(true);
try {
// 并行获取交易数据和涨幅分析
const [tradeRes, riseAnalysisRes] = await Promise.all([
marketService.getTradeData(stockCode, period),
marketService.getRiseAnalysis(stockCode),
]);
// 更新交易数据
if (tradeRes.success && tradeRes.data) {
setTradeData(tradeRes.data);
// 重建涨幅分析映射
if (riseAnalysisRes.success && riseAnalysisRes.data) {
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
riseAnalysisRes.data.forEach((analysis) => {
const dateIndex = tradeRes.data.findIndex(
(item) => item.date.substring(0, 10) === analysis.trade_date
);
if (dateIndex !== -1) {
tempAnalysisMap[dateIndex] = analysis;
}
});
setAnalysisMap(tempAnalysisMap);
}
}
logger.info('useMarketData', '日K线数据刷新成功', { stockCode, period });
} catch (error) {
logger.error('useMarketData', 'refreshTradeData', error, { stockCode, period });
} finally {
setTradeLoading(false);
}
}, [stockCode, period]);
/**
* 刷新所有数据
*/
@@ -166,16 +218,32 @@ export const useMarketData = (
await Promise.all([loadMarketData(), loadMinuteData()]);
}, [loadMarketData, loadMinuteData]);
// 监听股票代码和周期变化,自动加载数据
// 监听股票代码变化,加载所有数据(首次加载或切换股票)
useEffect(() => {
if (stockCode) {
loadMarketData();
loadMinuteData();
// stockCode 变化时,加载所有数据
if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) {
loadMarketData();
loadMinuteData();
prevStockCodeRef.current = stockCode;
prevPeriodRef.current = period; // 同步重置 period ref避免切换股票后误触发 refreshTradeData
isInitializedRef.current = true;
}
}
}, [stockCode, period, loadMarketData, loadMinuteData]);
// 监听时间周期变化只刷新日K线数据
useEffect(() => {
// 只有在已初始化后,且 period 真正变化时才单独刷新交易数据
if (stockCode && isInitializedRef.current && period !== prevPeriodRef.current) {
refreshTradeData();
prevPeriodRef.current = period;
}
}, [period, refreshTradeData, stockCode]);
return {
loading,
tradeLoading,
summary,
tradeData,
fundingData,
@@ -187,6 +255,7 @@ export const useMarketData = (
analysisMap,
refetch,
loadMinuteData,
refreshTradeData,
};
};

View File

@@ -1,37 +1,26 @@
// src/views/Company/components/MarketDataView/index.tsx
// MarketDataView 主组件 - 股票市场数据综合展示
import React, { useState, useEffect, ReactNode } from 'react';
import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react';
import {
Box,
Container,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Text,
CardBody,
Spinner,
Center,
VStack,
HStack,
Select,
Button,
Icon,
useDisclosure,
} from '@chakra-ui/react';
import {
ChevronUpIcon,
RepeatIcon,
ArrowUpIcon,
StarIcon,
LockIcon,
UnlockIcon,
} from '@chakra-ui/icons';
Unlock,
ArrowUp,
Star,
Lock,
} from 'lucide-react';
// 通用组件
import SubTabContainer from '@components/SubTabContainer';
import type { SubTabConfig } from '@components/SubTabContainer';
// 内部模块导入
import { themes, DEFAULT_PERIOD, PERIOD_OPTIONS } from './constants';
import { themes, DEFAULT_PERIOD } from './constants';
import { useMarketData } from './hooks/useMarketData';
import {
ThemedCard,
@@ -46,6 +35,7 @@ import {
UnusualPanel,
PledgePanel,
} from './components/panels';
import LoadingState from '../LoadingState';
import type { MarketDataViewProps, RiseAnalysis } from './types';
/**
@@ -88,190 +78,103 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
}, [propStockCode, stockCode]);
// 处理图表点击事件
const handleChartClick = (params: { seriesName?: string; data?: [number, number] }) => {
if (params.seriesName === '涨幅分析' && params.data) {
const dataIndex = params.data[0];
const analysis = analysisMap[dataIndex];
const handleChartClick = useCallback(
(params: { seriesName?: string; data?: [number, number] }) => {
if (params.seriesName === '涨幅分析' && params.data) {
const dataIndex = params.data[0];
const analysis = analysisMap[dataIndex];
if (analysis) {
setModalContent(<AnalysisContent analysis={analysis} theme={theme} />);
onOpen();
if (analysis) {
setModalContent(<AnalysisContent analysis={analysis} theme={theme} />);
onOpen();
}
}
}
};
},
[analysisMap, theme, onOpen]
);
// Tab 配置 - 使用通用 SubTabContainer不含交易数据交易数据单独显示在上方
const tabConfigs: SubTabConfig[] = [
{ key: 'funding', name: '融资融券', icon: Unlock, component: FundingPanel },
{ key: 'bigDeal', name: '大宗交易', icon: ArrowUp, component: BigDealPanel },
{ key: 'unusual', name: '龙虎榜', icon: Star, component: UnusualPanel },
{ key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel },
];
// 传递给 Tab 组件的 props
const componentProps = useMemo(
() => ({
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
onLoadMinuteData: loadMinuteData,
onChartClick: handleChartClick,
selectedPeriod,
onPeriodChange: setSelectedPeriod,
fundingData,
bigDealData,
unusualData,
pledgeData,
}),
[
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
loadMinuteData,
handleChartClick,
selectedPeriod,
fundingData,
bigDealData,
unusualData,
pledgeData,
]
);
return (
<Box bg={theme.bgMain} minH="100vh" color={theme.textPrimary}>
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
<Container maxW="container.xl" py={6}>
<VStack spacing={6} align="stretch">
<VStack align="stretch" spacing={6}>
{/* 股票概览 */}
{summary && <StockSummaryCard summary={summary} theme={theme} />}
{/* 主要内容区域 */}
{/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */}
{!loading && (
<TradeDataPanel
theme={theme}
tradeData={tradeData}
minuteData={minuteData}
minuteLoading={minuteLoading}
analysisMap={analysisMap}
onLoadMinuteData={loadMinuteData}
onChartClick={handleChartClick}
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
/>
)}
{/* 主要内容区域 - Tab */}
{loading ? (
<ThemedCard theme={theme}>
<CardBody>
<Center h="400px">
<VStack spacing={4}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={theme.bgDark}
color={theme.primary}
size="xl"
/>
<Text color={theme.textSecondary}>...</Text>
</VStack>
</Center>
</CardBody>
</ThemedCard>
) : (
<Tabs
variant="soft-rounded"
colorScheme="blue"
index={activeTab}
onChange={setActiveTab}
<Box
bg="gray.900"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
borderRadius="xl"
>
{/* Tab 导航栏 */}
<Box
bg={theme.bgCard}
p={4}
borderRadius="xl"
border="1px solid"
borderColor={theme.border}
>
<HStack justify="space-between" align="center" spacing={4}>
<TabList overflowX="auto" border="none" flex="1">
<Tab
color={theme.textMuted}
_selected={{ color: 'white', bg: theme.primary }}
fontSize="sm"
px={3}
>
<HStack spacing={1}>
<Icon as={ChevronUpIcon} boxSize={4} />
<Text></Text>
</HStack>
</Tab>
<Tab
color={theme.textMuted}
_selected={{ color: 'white', bg: theme.primary }}
fontSize="sm"
px={3}
>
<HStack spacing={1}>
<Icon as={UnlockIcon} boxSize={4} />
<Text></Text>
</HStack>
</Tab>
<Tab
color={theme.textMuted}
_selected={{ color: 'white', bg: theme.primary }}
fontSize="sm"
px={3}
>
<HStack spacing={1}>
<Icon as={ArrowUpIcon} boxSize={4} />
<Text></Text>
</HStack>
</Tab>
<Tab
color={theme.textMuted}
_selected={{ color: 'white', bg: theme.primary }}
fontSize="sm"
px={3}
>
<HStack spacing={1}>
<Icon as={StarIcon} boxSize={4} />
<Text></Text>
</HStack>
</Tab>
<Tab
color={theme.textMuted}
_selected={{ color: 'white', bg: theme.primary }}
fontSize="sm"
px={3}
>
<HStack spacing={1}>
<Icon as={LockIcon} boxSize={4} />
<Text></Text>
</HStack>
</Tab>
</TabList>
{/* 时间范围选择和刷新按钮 */}
<HStack spacing={2} flexShrink={0} ml="auto">
<Text color={theme.textPrimary} whiteSpace="nowrap" fontSize="sm">
</Text>
<Select
size="sm"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(Number(e.target.value))}
bg={theme.bgDark}
borderColor={theme.border}
color={theme.textPrimary}
maxW="120px"
>
{PERIOD_OPTIONS.map((option) => (
<option
key={option.value}
value={option.value}
style={{ background: theme.bgDark }}
>
{option.label}
</option>
))}
</Select>
<Button
leftIcon={<RepeatIcon />}
variant="outline"
colorScheme="blue"
onClick={refetch}
isLoading={loading}
size="sm"
>
</Button>
</HStack>
</HStack>
</Box>
<TabPanels>
{/* 交易数据 Tab */}
<TabPanel px={0}>
<TradeDataPanel
theme={theme}
tradeData={tradeData}
minuteData={minuteData}
minuteLoading={minuteLoading}
analysisMap={analysisMap}
onLoadMinuteData={loadMinuteData}
onChartClick={handleChartClick}
/>
</TabPanel>
{/* 融资融券 Tab */}
<TabPanel px={0}>
<FundingPanel theme={theme} fundingData={fundingData} />
</TabPanel>
{/* 大宗交易 Tab */}
<TabPanel px={0}>
<BigDealPanel theme={theme} bigDealData={bigDealData} />
</TabPanel>
{/* 龙虎榜 Tab */}
<TabPanel px={0}>
<UnusualPanel theme={theme} unusualData={unusualData} />
</TabPanel>
{/* 股权质押 Tab */}
<TabPanel px={0}>
<PledgePanel theme={theme} pledgeData={pledgeData} />
</TabPanel>
</TabPanels>
</Tabs>
<LoadingState message="数据加载中..." height="400px" />
</Box>
) : (
<SubTabContainer
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onTabChange={(index) => setActiveTab(index)}
isLazy
/>
)}
</VStack>
</Container>

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/services/marketService.ts
// MarketDataView API 服务层
import { getApiBase } from '@utils/apiConfig';
import axios from '@utils/axiosConfig';
import { logger } from '@utils/logger';
import type {
MarketSummary,
@@ -23,27 +23,6 @@ interface ApiResponse<T> {
message?: string;
}
/**
* API 基础 URL
*/
const getBaseUrl = (): string => getApiBase();
/**
* 通用 API 请求函数
*/
const apiRequest = async <T>(url: string): Promise<ApiResponse<T>> => {
try {
const response = await fetch(`${getBaseUrl()}${url}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
};
/**
* 市场数据服务
*/
@@ -53,7 +32,8 @@ export const marketService = {
* @param stockCode 股票代码
*/
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
return apiRequest<MarketSummary>(`/api/market/summary/${stockCode}`);
const { data } = await axios.get<ApiResponse<MarketSummary>>(`/api/market/summary/${stockCode}`);
return data;
},
/**
@@ -62,7 +42,8 @@ export const marketService = {
* @param days 天数,默认 60 天
*/
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
return apiRequest<TradeDayData[]>(`/api/market/trade/${stockCode}?days=${days}`);
const { data } = await axios.get<ApiResponse<TradeDayData[]>>(`/api/market/trade/${stockCode}?days=${days}`);
return data;
},
/**
@@ -71,7 +52,8 @@ export const marketService = {
* @param days 天数,默认 30 天
*/
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
return apiRequest<FundingDayData[]>(`/api/market/funding/${stockCode}?days=${days}`);
const { data } = await axios.get<ApiResponse<FundingDayData[]>>(`/api/market/funding/${stockCode}?days=${days}`);
return data;
},
/**
@@ -80,11 +62,8 @@ export const marketService = {
* @param days 天数,默认 30 天
*/
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
const { data } = await axios.get<BigDealData>(`/api/market/bigdeal/${stockCode}?days=${days}`);
return data;
},
/**
@@ -93,11 +72,8 @@ export const marketService = {
* @param days 天数,默认 30 天
*/
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
const { data } = await axios.get<UnusualData>(`/api/market/unusual/${stockCode}?days=${days}`);
return data;
},
/**
@@ -105,7 +81,8 @@ export const marketService = {
* @param stockCode 股票代码
*/
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
return apiRequest<PledgeData[]>(`/api/market/pledge/${stockCode}`);
const { data } = await axios.get<ApiResponse<PledgeData[]>>(`/api/market/pledge/${stockCode}`);
return data;
},
/**
@@ -123,7 +100,8 @@ export const marketService = {
if (startDate && endDate) {
url += `?start_date=${startDate}&end_date=${endDate}`;
}
return apiRequest<RiseAnalysis[]>(url);
const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url);
return data;
},
/**
@@ -132,18 +110,8 @@ export const marketService = {
*/
async getMinuteData(stockCode: string): Promise<MinuteData> {
try {
const response = await fetch(`${getBaseUrl()}/api/stock/${stockCode}/latest-minute`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const { data } = await axios.get<MinuteData>(`/api/stock/${stockCode}/latest-minute`);
if (!response.ok) {
throw new Error('Failed to fetch minute data');
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
return data;
}

View File

@@ -270,7 +270,7 @@ export interface MarkdownRendererProps {
*/
export interface StockSummaryCardProps {
summary: MarketSummary;
theme: Theme;
theme?: Theme; // 可选StockSummaryCard 使用内置黑金主题
}
/**
@@ -287,31 +287,18 @@ export interface TradeDataTabProps {
}
/**
* KLineChart 组件 Props
* KLineModule 组件 Props日K/分钟K线切换模块
*/
export interface KLineChartProps {
export interface KLineModuleProps {
theme: Theme;
tradeData: TradeDayData[];
analysisMap: Record<number, RiseAnalysis>;
onAnalysisClick: (analysis: RiseAnalysis) => void;
}
/**
* MinuteKLineChart 组件 Props
*/
export interface MinuteKLineChartProps {
theme: Theme;
minuteData: MinuteData | null;
loading: boolean;
onRefresh: () => void;
}
/**
* TradeTable 组件 Props
*/
export interface TradeTableProps {
theme: Theme;
tradeData: TradeDayData[];
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
onLoadMinuteData: () => void;
onChartClick: (params: { seriesName?: string; data?: [number, number] }) => void;
selectedPeriod?: number;
onPeriodChange?: (period: number) => void;
}
/**
@@ -369,6 +356,7 @@ export interface AnalysisModalContentProps {
*/
export interface UseMarketDataReturn {
loading: boolean;
tradeLoading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
fundingData: FundingDayData[];
@@ -380,4 +368,5 @@ export interface UseMarketDataReturn {
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
refreshTradeData: () => Promise<void>;
}

View File

@@ -131,15 +131,17 @@ export const getKLineOption = (
],
grid: [
{
left: '10%',
right: '10%',
left: '3%',
right: '3%',
height: '50%',
containLabel: true,
},
{
left: '10%',
right: '10%',
left: '3%',
right: '3%',
top: '65%',
height: '20%',
containLabel: true,
},
],
series: [
@@ -312,16 +314,18 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null
},
grid: [
{
left: '8%',
right: '8%',
left: '3%',
right: '3%',
top: '20%',
height: '60%',
containLabel: true,
},
{
left: '8%',
right: '8%',
left: '3%',
right: '3%',
top: '83%',
height: '12%',
containLabel: true,
},
],
xAxis: [
@@ -441,6 +445,437 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null
};
};
/**
* 生成日K线图配置 - 黑金主题
*/
export const getKLineDarkGoldOption = (
tradeData: TradeDayData[],
analysisMap: Record<number, RiseAnalysis>
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
// 黑金主题色
const gold = '#D4AF37';
const goldLight = '#F4D03F';
const orange = '#FF9500';
const red = '#FF4444';
const green = '#00C851';
const textColor = 'rgba(255, 255, 255, 0.85)';
const textMuted = 'rgba(255, 255, 255, 0.5)';
const borderColor = 'rgba(212, 175, 55, 0.2)';
const dates = tradeData.map((item) => item.date.substring(5, 10));
const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = tradeData.map((item) => item.volume);
const closePrices = tradeData.map((item) => item.close);
const ma5 = calculateMA(closePrices, 5);
const ma10 = calculateMA(closePrices, 10);
const ma20 = calculateMA(closePrices, 20);
// 创建涨幅分析标记点
const scatterData: [number, number][] = [];
Object.keys(analysisMap).forEach((dateIndex) => {
const idx = parseInt(dateIndex);
if (tradeData[idx]) {
const value = tradeData[idx].high * 1.02;
scatterData.push([idx, value]);
}
});
return {
backgroundColor: 'transparent',
animation: true,
legend: {
data: ['K线', 'MA5', 'MA10', 'MA20'],
top: 5,
left: 'center',
textStyle: {
color: textColor,
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
lineStyle: {
color: gold,
width: 1,
opacity: 0.8,
},
},
backgroundColor: 'rgba(26, 26, 46, 0.95)',
borderColor: gold,
borderWidth: 1,
textStyle: {
color: textColor,
},
},
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: false,
axisLine: { onZero: false, lineStyle: { color: borderColor } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
yAxis: [
{
scale: true,
splitLine: {
show: true,
lineStyle: {
color: borderColor,
type: 'dashed',
},
},
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
grid: [
{
left: '3%',
right: '3%',
top: '8%',
height: '55%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '68%',
height: '28%',
containLabel: true,
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: red,
color0: green,
borderColor: red,
borderColor0: green,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
lineStyle: {
color: gold,
width: 1,
},
itemStyle: {
color: gold,
},
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
lineStyle: {
color: goldLight,
width: 1,
},
itemStyle: {
color: goldLight,
},
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
lineStyle: {
color: orange,
width: 1,
},
itemStyle: {
color: orange,
},
},
{
name: '涨幅分析',
type: 'scatter',
data: scatterData,
symbolSize: [80, 36],
symbol: 'roundRect',
itemStyle: {
color: 'rgba(26, 26, 46, 0.9)',
borderColor: gold,
borderWidth: 1,
shadowBlur: 8,
shadowColor: 'rgba(212, 175, 55, 0.4)',
},
label: {
show: true,
formatter: '涨幅分析\n(点击查看)',
fontSize: 10,
lineHeight: 12,
position: 'inside',
color: gold,
fontWeight: 'bold',
},
emphasis: {
scale: false,
itemStyle: {
borderColor: goldLight,
borderWidth: 2,
},
},
z: 100,
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = tradeData[params.dataIndex];
return item.change_percent >= 0
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成分钟K线图配置 - 黑金主题
*/
export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): EChartsOption => {
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
// 黑金主题色
const gold = '#D4AF37';
const goldLight = '#F4D03F';
const orange = '#FF9500';
const red = '#FF4444';
const green = '#00C851';
const textColor = 'rgba(255, 255, 255, 0.85)';
const textMuted = 'rgba(255, 255, 255, 0.5)';
const borderColor = 'rgba(212, 175, 55, 0.2)';
const times = minuteData.data.map((item) => item.time);
const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = minuteData.data.map((item) => item.volume);
const closePrices = minuteData.data.map((item) => item.close);
const avgPrice = calculateMA(closePrices, 5);
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(26, 26, 46, 0.95)',
borderColor: gold,
borderWidth: 1,
textStyle: {
color: textColor,
fontSize: 12,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
let result = `<span style="color: ${gold}">${paramsArr[0].name}</span><br/>`;
paramsArr.forEach((param) => {
if (param.seriesName === '分钟K线') {
const [open, close, , high] = param.data as number[];
const low = (param.data as number[])[2];
const changePercent =
openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00';
result += `${param.marker} ${param.seriesName}<br/>`;
result += `开盘: <span style="font-weight: bold; color: ${goldLight}">${open.toFixed(2)}</span><br/>`;
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? red : green}">${close.toFixed(2)}</span><br/>`;
result += `最高: <span style="font-weight: bold; color: ${goldLight}">${high.toFixed(2)}</span><br/>`;
result += `最低: <span style="font-weight: bold; color: ${goldLight}">${low.toFixed(2)}</span><br/>`;
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? red : green}">${changePercent}%</span><br/>`;
} else if (param.seriesName === '均价线') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold; color: ${goldLight}">${(param.value as number).toFixed(2)}</span><br/>`;
} else if (param.seriesName === '成交量') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold; color: ${goldLight}">${formatNumber(param.value as number, 0)}</span><br/>`;
}
});
return result;
},
},
legend: {
data: ['分钟K线', '均价线', '成交量'],
top: 5,
left: 'center',
textStyle: {
color: textColor,
fontSize: 12,
},
itemWidth: 25,
itemHeight: 14,
},
grid: [
{
left: '3%',
right: '3%',
top: '10%',
height: '65%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '78%',
height: '15%',
containLabel: true,
},
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: {
color: textMuted,
fontSize: 10,
interval: 'auto',
},
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: {
color: textMuted,
fontSize: 10,
},
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted, fontSize: 10 },
splitLine: {
lineStyle: {
color: borderColor,
type: 'dashed',
},
},
},
{
gridIndex: 1,
scale: true,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted, fontSize: 10 },
splitLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100,
minValueSpan: 20,
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
top: '95%',
start: 70,
end: 100,
height: 20,
handleSize: '100%',
handleStyle: {
color: gold,
},
textStyle: {
color: textMuted,
},
borderColor: borderColor,
fillerColor: 'rgba(212, 175, 55, 0.2)',
},
],
series: [
{
name: '分钟K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: red,
color0: green,
borderColor: red,
borderColor0: green,
borderWidth: 1,
},
barWidth: '60%',
},
{
name: '均价线',
type: 'line',
data: avgPrice,
smooth: true,
symbol: 'none',
lineStyle: {
color: gold,
width: 2,
opacity: 0.8,
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
barWidth: '50%',
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = minuteData.data[params.dataIndex];
return item.close >= item.open
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成融资融券图表配置
*/
@@ -575,6 +1010,154 @@ export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): E
};
};
/**
* 生成融资融券图表配置 - 黑金主题
*/
export const getFundingDarkGoldOption = (fundingData: FundingDayData[]): EChartsOption => {
if (!fundingData || fundingData.length === 0) return {};
const dates = fundingData.map((item) => item.date.substring(5, 10));
const financing = fundingData.map((item) => item.financing.balance / 100000000);
const securities = fundingData.map((item) => item.securities.balance_amount / 100000000);
// 黑金主题色
const gold = '#D4AF37';
const goldLight = '#F4D03F';
const textColor = 'rgba(255, 255, 255, 0.85)';
const textMuted = 'rgba(255, 255, 255, 0.5)';
const borderColor = 'rgba(212, 175, 55, 0.2)';
return {
backgroundColor: 'transparent',
title: {
text: '融资融券余额走势',
left: 'center',
textStyle: {
color: gold,
fontSize: 16,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(26, 26, 46, 0.95)',
borderColor: gold,
borderWidth: 1,
textStyle: {
color: textColor,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
let result = `<span style="color: ${gold}">${paramsArr[0].name}</span><br/>`;
paramsArr.forEach((param) => {
result += `${param.marker} ${param.seriesName}: <span style="color: ${goldLight}; font-weight: bold">${param.value.toFixed(2)}亿</span><br/>`;
});
return result;
},
},
legend: {
data: ['融资余额', '融券余额'],
bottom: 10,
textStyle: {
color: textColor,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
splitLine: { show: false },
},
yAxis: {
type: 'value',
name: '金额(亿)',
nameTextStyle: { color: textMuted },
splitLine: {
lineStyle: {
color: borderColor,
type: 'dashed',
},
},
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
},
series: [
{
name: '融资余额',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(212, 175, 55, 0.4)' },
{ offset: 1, color: 'rgba(212, 175, 55, 0.05)' },
],
},
},
lineStyle: {
color: gold,
width: 2,
shadowBlur: 10,
shadowColor: 'rgba(212, 175, 55, 0.5)',
},
itemStyle: {
color: gold,
borderColor: goldLight,
borderWidth: 2,
},
data: financing,
},
{
name: '融券余额',
type: 'line',
smooth: true,
symbol: 'diamond',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 149, 0, 0.4)' },
{ offset: 1, color: 'rgba(255, 149, 0, 0.05)' },
],
},
},
lineStyle: {
color: '#FF9500',
width: 2,
shadowBlur: 10,
shadowColor: 'rgba(255, 149, 0, 0.5)',
},
itemStyle: {
color: '#FF9500',
borderColor: '#FFB347',
borderWidth: 2,
},
data: securities,
},
],
};
};
/**
* 生成股权质押图表配置
*/
@@ -689,10 +1272,140 @@ export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): ECharts
};
};
/**
* 生成股权质押图表配置 - 黑金主题
*/
export const getPledgeDarkGoldOption = (pledgeData: PledgeData[]): EChartsOption => {
if (!pledgeData || pledgeData.length === 0) return {};
const dates = pledgeData.map((item) => item.end_date.substring(5, 10));
const ratios = pledgeData.map((item) => item.pledge_ratio);
const counts = pledgeData.map((item) => item.pledge_count);
// 黑金主题色
const gold = '#D4AF37';
const goldLight = '#F4D03F';
const orange = '#FF9500';
const textColor = 'rgba(255, 255, 255, 0.85)';
const textMuted = 'rgba(255, 255, 255, 0.5)';
const borderColor = 'rgba(212, 175, 55, 0.2)';
return {
backgroundColor: 'transparent',
title: {
text: '股权质押趋势',
left: 'center',
textStyle: {
color: gold,
fontSize: 16,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(26, 26, 46, 0.95)',
borderColor: gold,
borderWidth: 1,
textStyle: {
color: textColor,
},
},
legend: {
data: ['质押比例', '质押笔数'],
bottom: 10,
textStyle: {
color: textColor,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
splitLine: { show: false },
},
yAxis: [
{
type: 'value',
name: '质押比例(%)',
nameTextStyle: { color: textMuted },
splitLine: {
lineStyle: {
color: borderColor,
type: 'dashed',
},
},
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
},
{
type: 'value',
name: '质押笔数',
nameTextStyle: { color: textMuted },
axisLine: { lineStyle: { color: borderColor } },
axisLabel: { color: textMuted },
splitLine: { show: false },
},
],
series: [
{
name: '质押比例',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: gold,
width: 2,
shadowBlur: 10,
shadowColor: 'rgba(212, 175, 55, 0.5)',
},
itemStyle: {
color: gold,
borderColor: goldLight,
borderWidth: 2,
},
data: ratios,
},
{
name: '质押笔数',
type: 'bar',
yAxisIndex: 1,
barWidth: '50%',
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: orange },
{ offset: 1, color: 'rgba(255, 149, 0, 0.3)' },
],
},
borderRadius: [4, 4, 0, 0],
},
data: counts,
},
],
};
};
export default {
calculateMA,
getKLineOption,
getKLineDarkGoldOption,
getMinuteKLineOption,
getMinuteKLineDarkGoldOption,
getFundingOption,
getFundingDarkGoldOption,
getPledgeOption,
getPledgeDarkGoldOption,
};

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