Compare commits

...

136 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,232 @@
/**
* SubTabContainer - 二级导航容器组件
*
* 用于模块内的子功能切换(如公司档案下的股权结构、管理团队等)
* 与 TabContainer一级导航区分无 Card 包裹,直接融入父容器
*
* @example
* ```tsx
* <SubTabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
* ]}
* componentProps={{ stockCode: '000001' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
* ```
*/
import React, { useState, useCallback, memo } from 'react';
import {
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
HStack,
Text,
Spacer,
} from '@chakra-ui/react';
import type { ComponentType } from 'react';
import type { IconType } from 'react-icons';
/**
* Tab 配置项
*/
export interface SubTabConfig {
key: string;
name: string;
icon?: IconType | ComponentType;
component?: ComponentType<any>;
}
/**
* 主题配置
*/
export interface SubTabTheme {
bg: string;
borderColor: string;
tabSelectedBg: string;
tabSelectedColor: string;
tabUnselectedColor: string;
tabHoverBg: string;
}
/**
* 预设主题
*/
const THEME_PRESETS: Record<string, SubTabTheme> = {
blackGold: {
bg: 'gray.900',
borderColor: 'rgba(212, 175, 55, 0.3)',
tabSelectedBg: '#D4AF37',
tabSelectedColor: 'gray.900',
tabUnselectedColor: '#D4AF37',
tabHoverBg: 'gray.600',
},
default: {
bg: 'white',
borderColor: 'gray.200',
tabSelectedBg: 'blue.500',
tabSelectedColor: 'white',
tabUnselectedColor: 'gray.600',
tabHoverBg: 'gray.100',
},
};
export interface SubTabContainerProps {
/** Tab 配置数组 */
tabs: SubTabConfig[];
/** 传递给 Tab 内容组件的 props */
componentProps?: Record<string, any>;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string) => void;
/** 主题预设 */
themePreset?: 'blackGold' | 'default';
/** 自定义主题(优先级高于预设) */
theme?: Partial<SubTabTheme>;
/** 内容区内边距 */
contentPadding?: number;
/** 是否懒加载 */
isLazy?: boolean;
/** TabList 右侧自定义内容 */
rightElement?: React.ReactNode;
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
tabs,
componentProps = {},
defaultIndex = 0,
index: controlledIndex,
onTabChange,
themePreset = 'blackGold',
theme: customTheme,
contentPadding = 4,
isLazy = true,
rightElement,
}) => {
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
// 当前索引
const currentIndex = controlledIndex ?? internalIndex;
// 记录已访问的 Tab 索引(用于真正的懒加载)
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
() => new Set([controlledIndex ?? defaultIndex])
);
// 合并主题
const theme: SubTabTheme = {
...THEME_PRESETS[themePreset],
...customTheme,
};
/**
* 处理 Tab 切换
*/
const handleTabChange = useCallback(
(newIndex: number) => {
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);
}
},
[tabs, onTabChange, controlledIndex]
);
return (
<Box>
<Tabs
isLazy={isLazy}
variant="unstyled"
index={currentIndex}
onChange={handleTabChange}
>
<TabList
bg={theme.bg}
borderBottom="1px solid"
borderColor={theme.borderColor}
pl={0}
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={2.5}
py={1.5}
fontSize="xs"
whiteSpace="nowrap"
flexShrink={0}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: 'bold',
}}
_hover={{
bg: theme.tabHoverBg,
}}
>
<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, idx) => {
const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
return (
<TabPanel key={tab.key} p={0}>
{shouldRender && Component ? (
<Component {...componentProps} />
) : null}
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</Box>
);
});
SubTabContainer.displayName = 'SubTabContainer';
export default SubTabContainer;

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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, // 百万元
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,
gross_profit: 116730 - i * 2800,
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,
eps: 2.72 - i * 0.06
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, // %
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_margin: 32.56 - i * 0.3,
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,
debt_ratio: 93.52 + i * 0.05,
asset_turnover: 0.41 - i * 0.01,
inventory_turnover: 0, // 银行无库存
receivable_turnover: 0 // 银行特殊
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,
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
industryRank: [
{
period: '2024-09-30',
report_type: '三季报',
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 }
{
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: [
// 期间对比 - 营收与利润趋势数据
periodComparison: [
{
name: '营业收入',
unit: '百万元',
values: [41500, 40800, 40200, 40850],
yoy: [8.2, 7.8, 8.5, 9.2]
period: '2024-09-30',
performance: {
revenue: 41500000000, // 415亿
net_profit: 13420000000 // 134.2亿
}
},
{
name: '净利润',
unit: '百万元',
values: [13420, 13180, 13050, 13210],
yoy: [12.5, 11.2, 10.8, 12.3]
period: '2024-06-30',
performance: {
revenue: 40800000000, // 408亿
net_profit: 13180000000 // 131.8亿
}
},
{
name: 'ROE',
unit: '%',
values: [16.23, 15.98, 15.75, 16.02],
yoy: [1.2, 0.8, 0.5, 1.0]
period: '2024-03-31',
performance: {
revenue: 40200000000, // 402亿
net_profit: 13050000000 // 130.5亿
}
},
{
name: 'EPS',
unit: '元',
values: [0.69, 0.68, 0.67, 0.68],
yoy: [12.3, 11.5, 10.5, 12.0]
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

@@ -24,8 +24,9 @@ export const generateMarketData = (stockCode) => {
low: parseFloat(low.toFixed(2)),
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3%
pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7
};
})
},
@@ -78,36 +79,45 @@ export const generateMarketData = (stockCode) => {
}))
},
// 股权质押
// 股权质押 - 匹配 PledgeData[] 类型
pledgeData: {
success: true,
data: {
total_pledged: 25.6, // 质押比例%
major_shareholders: [
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
],
update_date: '2024-09-30'
}
data: Array(12).fill(null).map((_, i) => {
const date = new Date();
date.setMonth(date.getMonth() - (11 - i));
return {
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000,
total_pledge: Math.floor(Math.random() * 1200000000) + 550000000,
total_shares: 19405918198,
pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9%
pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150
};
})
},
// 市场摘要
// 市场摘要 - 匹配 MarketSummary 类型
summaryData: {
success: true,
data: {
current_price: basePrice,
change: 0.25,
change_pct: 1.89,
open: 13.35,
high: 13.68,
low: 13.28,
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
latest_trade: {
close: basePrice,
change_percent: 1.89,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96,
pb_ratio: 0.72,
total_market_cap: 262300000000,
circulating_market_cap: 262300000000
pe_ratio: 4.96
},
latest_funding: {
financing_balance: 5823000000,
securities_balance: 125600000
},
latest_pledge: {
pledge_ratio: 8.25
}
}
},
@@ -131,26 +141,57 @@ export const generateMarketData = (stockCode) => {
})
},
// 最新分时数据
// 最新分时数据 - 匹配 MinuteData 类型
latestMinuteData: {
success: true,
data: Array(240).fill(null).map((_, i) => {
const minute = 9 * 60 + 30 + i; // 从9:30开始
const hour = Math.floor(minute / 60);
const min = minute % 60;
data: (() => {
const minuteData = [];
// 上午 9:30-11:30 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 9 + Math.floor((30 + i) / 60);
const min = (30 + i) % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
return {
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
price: (basePrice + randomChange).toFixed(2),
open,
close,
high,
low,
volume: Math.floor(Math.random() * 2000000) + 500000,
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
};
}),
amount: Math.floor(Math.random() * 30000000) + 5000000
});
}
// 下午 13:00-15:00 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 13 + Math.floor(i / 60);
const min = i % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
open,
close,
high,
low,
volume: Math.floor(Math.random() * 1500000) + 400000,
amount: Math.floor(Math.random() * 25000000) + 4000000
});
}
return minuteData;
})(),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
trade_date: new Date().toISOString().split('T')[0],
type: 'minute'
type: '1min'
}
};
};

View File

@@ -43,12 +43,10 @@ export const companyHandlers = [
const { stockCode } = params;
const data = getCompanyData(stockCode);
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline
return HttpResponse.json({
success: true,
data: {
timeline: data.keyFactorsTimeline,
total: data.keyFactorsTimeline.length
}
data: data.keyFactorsTimeline
});
}),
@@ -69,10 +67,19 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.actualControl;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1
const formatted = Array.isArray(raw)
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({
success: true,
data: data.actualControl
data: formatted
});
}),
@@ -81,10 +88,19 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.concentration;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1
const formatted = Array.isArray(raw)
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({
success: true,
data: data.concentration
data: formatted
});
}),

View File

@@ -120,9 +120,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) {
@@ -136,16 +139,14 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: {
events: [],
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
pages: 0,
has_prev: false,
has_next: false
}
},
{ status: 500 }

View File

@@ -387,6 +387,68 @@ export const stockHandlers = [
}
}),
// 获取股票业绩预告
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
const stockName = stockInfo?.name || `股票${stockCode}`;
// 业绩预告类型列表
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
// 生成业绩预告数据
const forecasts = [
{
forecast_type: '预增',
report_date: '2024年年报',
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元同比增长10%至17%。`,
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
change_range: {
lower: 10,
upper: 17
},
publish_date: '2024-10-15'
},
{
forecast_type: '略增',
report_date: '2024年三季报',
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元同比增长5%至12%。`,
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
change_range: {
lower: 5,
upper: 12
},
publish_date: '2024-07-12'
},
{
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
report_date: '2024年中报',
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
change_range: {
lower: 3,
upper: 8
},
publish_date: '2024-04-20'
}
];
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
forecasts: forecasts
}
});
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
@@ -414,6 +476,25 @@ export const stockHandlers = [
stockMap[s.code] = s.name;
});
// 行业和指数映射表
const stockIndustryMap = {
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
};
const defaultIndustries = [
{ industry_l1: '科技', industry: '软件' },
{ industry_l1: '医药', industry: '化学制药' },
{ industry_l1: '消费', industry: '食品' },
{ industry_l1: '金融', industry: '证券' },
{ industry_l1: '工业', industry: '机械' },
];
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
@@ -426,6 +507,11 @@ export const stockHandlers = [
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
// 获取行业和指数信息
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
@@ -439,7 +525,23 @@ export const stockHandlers = [
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
update_time: new Date().toISOString(),
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || [],
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
// 主力动态
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
};
});

View File

@@ -35,9 +35,9 @@ export const lazyComponents = {
// 公司相关模块
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('@views/AgentChat')),

View File

@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ====================
/**
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async () => {
async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist');
try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include'
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
@@ -340,6 +409,26 @@ const stockSlice = createSlice({
delete state.historicalEventsCache[eventId];
delete state.chainAnalysisCache[eventId];
delete state.expectationScores[eventId];
},
/**
* 乐观更新:添加自选股(同步)
*/
optimisticAddWatchlist: (state, action) => {
const { stockCode, stockName } = action.payload;
// 避免重复添加
const exists = state.watchlist.some(item => item.stock_code === stockCode);
if (!exists) {
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
}
},
/**
* 乐观更新:移除自选股(同步)
*/
optimisticRemoveWatchlist: (state, action) => {
const { stockCode } = action.payload;
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
},
extraReducers: (builder) => {
@@ -470,9 +559,10 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
});
}
});
@@ -481,7 +571,9 @@ export const {
updateQuote,
updateQuotes,
clearQuotes,
clearEventCache
clearEventCache,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} = stockSlice.actions;
export default stockSlice.reducer;

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
// src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
Input,
InputGroup,
InputLeftElement,
Text,
VStack,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { useStockSearch } from '../../hooks/useStockSearch';
/**
* 股票搜索栏组件(带模糊搜索下拉)
*
* @param {Object} props
* @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyDown - 键盘事件回调
*/
const SearchBar = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
}) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 使用共享的搜索 Hook
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
// 根据搜索结果更新下拉显示状态
useEffect(() => {
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
}, [filteredStocks, inputCode]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票 - 直接触发搜索跳转
const handleSelectStock = (stock) => {
onInputChange(stock.code);
setShowDropdown(false);
onSearch(stock.code);
};
// 处理键盘事件
const handleKeyDownWrapper = (e) => {
if (e.key === 'Enter') {
setShowDropdown(false);
}
onKeyDown?.(e);
};
return (
<Box ref={containerRef} position="relative" w="300px">
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" />
</InputLeftElement>
<Input
placeholder="输入股票代码或名称"
value={inputCode}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDownWrapper}
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md"
color="white"
borderColor="#C9A961"
_placeholder={{ color: '#C9A961' }}
_focus={{
borderColor: '#F4D03F',
boxShadow: '0 0 0 1px #F4D03F',
}}
_hover={{
borderColor: '#F4D03F',
}}
/>
</InputGroup>
{/* 模糊搜索下拉列表 */}
{showDropdown && (
<Box
position="absolute"
top="100%"
left={0}
mt={1}
w="100%"
bg="#1A202C"
border="1px solid #C9A961"
borderRadius="md"
maxH="300px"
overflowY="auto"
zIndex={1000}
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
>
<VStack align="stretch" spacing={0}>
{filteredStocks.map((stock) => (
<Box
key={stock.code}
px={4}
py={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => handleSelectStock(stock)}
borderBottom="1px solid"
borderColor="whiteAlpha.100"
_last={{ borderBottom: 'none' }}
>
<HStack justify="space-between">
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
{stock.code}
</Text>
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
{stock.name}
</Text>
</HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
);
};
export default SearchBar;

View File

@@ -0,0 +1,62 @@
// src/views/Company/components/CompanyHeader/index.js
// 公司详情页面头部区域组件
import React from 'react';
import {
Card,
CardBody,
HStack,
VStack,
Heading,
Text,
} from '@chakra-ui/react';
import SearchBar from './SearchBar';
/**
* 公司详情页面头部区域组件
*
* 包含:
* - 页面标题和描述(金色主题)
* - 股票搜索栏(支持模糊搜索)
*
* @param {Object} props
* @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyDown - 键盘事件回调
* @param {string} props.bgColor - 背景颜色
*/
const CompanyHeader = ({
inputCode,
onInputChange,
onSearch,
onKeyDown,
bgColor,
}) => {
return (
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
{/* 标题区域 - 金色主题 */}
<VStack align="start" spacing={1}>
<Heading size="lg" color="#F4D03F">个股详情</Heading>
<Text color="#C9A961" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
{/* 搜索栏 */}
<SearchBar
inputCode={inputCode}
onInputChange={onInputChange}
onSearch={onSearch}
onKeyDown={onKeyDown}
/>
</HStack>
</CardBody>
</Card>
);
};
export default CompanyHeader;

View File

@@ -0,0 +1,157 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
// 公司公告 Tab Panel
import React, { useState } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
IconButton,
Button,
Tag,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps {
stockCode: string;
}
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
const { announcements, loading } = useAnnouncementsData(stockCode);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
const handleAnnouncementClick = (announcement: any) => {
setSelectedAnnouncement(announcement);
onOpen();
};
if (loading) {
return <LoadingState message="加载公告数据..." />;
}
return (
<>
<VStack spacing={4} align="stretch">
{/* 最新公告 */}
<Box>
<VStack spacing={2} align="stretch">
{announcements.map((announcement: any, idx: number) => (
<Card
key={idx}
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: THEME.tableHoverBg }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" bg={THEME.gold} color="gray.900">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(announcement.announce_date)}
</Text>
</HStack>
<Text fontSize="sm" fontWeight="medium" noOfLines={1} color={THEME.textPrimary}>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
color={THEME.goldLight}
aria-label="查看原文"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg={THEME.cardBg}>
<ModalHeader color={THEME.textPrimary}>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge bg={THEME.gold} color="gray.900">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color={THEME.textPrimary} />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
bg={THEME.gold}
color="gray.900"
mr={3}
_hover={{ bg: THEME.goldLight }}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
</Button>
<Button variant="ghost" color={THEME.textSecondary} onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default AnnouncementsPanel;

View File

@@ -0,0 +1,168 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
// 分支机构 Tab Panel - 黑金风格
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Icon,
SimpleGrid,
Center,
} from "@chakra-ui/react";
import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons/fa";
import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface BranchesPanelProps {
stockCode: string;
}
// 黑金卡片样式
const cardStyles = {
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
border: "1px solid",
borderColor: "rgba(212, 175, 55, 0.3)",
borderRadius: "12px",
overflow: "hidden",
transition: "all 0.3s ease",
_hover: {
borderColor: "rgba(212, 175, 55, 0.6)",
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
transform: "translateY(-2px)",
},
};
// 状态徽章样式
const getStatusBadgeStyles = (isActive: boolean) => ({
display: "inline-flex",
alignItems: "center",
gap: "4px",
px: 2,
py: 0.5,
borderRadius: "full",
fontSize: "xs",
fontWeight: "medium",
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
color: isActive ? THEME.gold : "#ff6b6b",
border: "1px solid",
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
});
// 信息项组件
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
<VStack align="start" spacing={0.5}>
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
{label}
</Text>
<Text fontSize="sm" fontWeight="semibold" color={THEME.textPrimary}>
{value || "-"}
</Text>
</VStack>
);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
if (loading) {
return <LoadingState message="加载分支机构数据..." />;
}
if (branches.length === 0) {
return (
<Center h="200px">
<VStack spacing={3}>
<Box
p={4}
borderRadius="full"
bg="rgba(212, 175, 55, 0.1)"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<Icon as={FaSitemap} boxSize={10} color={THEME.gold} opacity={0.6} />
</Box>
<Text color={THEME.textSecondary} fontSize="sm">
</Text>
</VStack>
</Center>
);
}
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch: any, idx: number) => {
const isActive = branch.business_status === "存续";
return (
<Box key={idx} sx={cardStyles}>
{/* 顶部金色装饰线 */}
<Box
h="2px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
/>
<Box p={4}>
<VStack align="start" spacing={4}>
{/* 标题行 */}
<HStack justify="space-between" w="full" align="flex-start">
<HStack spacing={2} flex={1}>
<Box
p={1.5}
borderRadius="md"
bg="rgba(212, 175, 55, 0.1)"
>
<Icon as={FaBuilding} boxSize={3.5} color={THEME.gold} />
</Box>
<Text
fontWeight="bold"
color={THEME.textPrimary}
fontSize="sm"
noOfLines={2}
lineHeight="tall"
>
{branch.branch_name}
</Text>
</HStack>
{/* 状态徽章 */}
<Box sx={getStatusBadgeStyles(isActive)}>
<Icon
as={isActive ? FaCheckCircle : FaTimesCircle}
boxSize={3}
/>
<Text>{branch.business_status}</Text>
</Box>
</HStack>
{/* 分隔线 */}
<Box
w="full"
h="1px"
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
/>
{/* 信息网格 */}
<SimpleGrid columns={2} spacing={3} w="full">
<InfoItem label="注册资本" value={branch.register_capital} />
<InfoItem label="法人代表" value={branch.legal_person} />
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
<InfoItem
label="关联企业"
value={`${branch.related_company_count || 0}`}
/>
</SimpleGrid>
</VStack>
</Box>
</Box>
);
})}
</SimpleGrid>
);
};
export default BranchesPanel;

View File

@@ -0,0 +1,121 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
// 工商信息 Tab Panel
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
SimpleGrid,
Divider,
Center,
Code,
Spinner,
} from "@chakra-ui/react";
import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo";
interface BusinessInfoPanelProps {
stockCode: string;
}
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">
<Text color={THEME.textSecondary}></Text>
</Center>
);
}
return (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}>
{basicInfo.credit_code}
</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider borderColor={THEME.border} />
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
);
};
export default BusinessInfoPanel;

View File

@@ -0,0 +1,76 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
// 财报披露日程 Tab Panel
import React from "react";
import {
Box,
VStack,
Text,
Badge,
Card,
CardBody,
SimpleGrid,
} from "@chakra-ui/react";
import { useDisclosureData } from "../../hooks/useDisclosureData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface DisclosureSchedulePanelProps {
stockCode: string;
}
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
if (loading) {
return <LoadingState message="加载披露日程..." />;
}
if (disclosureSchedule.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color={THEME.textSecondary}></Text>
</Box>
);
}
return (
<VStack spacing={4} align="stretch">
<Box>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.map((schedule: any, idx: number) => (
<Card
key={idx}
bg={schedule.is_disclosed ? "green.900" : "orange.900"}
border="1px solid"
borderColor={schedule.is_disclosed ? "green.600" : "orange.600"}
size="sm"
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge colorScheme={schedule.is_disclosed ? "green" : "orange"}>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
</VStack>
);
};
export default DisclosureSchedulePanel;

View File

@@ -0,0 +1,32 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
// 复用的加载状态组件
import React from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
import { THEME } from "../config";
interface LoadingStateProps {
message?: string;
height?: string;
}
/**
* 加载状态组件(黑金主题)
*/
const LoadingState: React.FC<LoadingStateProps> = ({
message = "加载中...",
height = "200px",
}) => {
return (
<Center h={height}>
<VStack>
<Spinner size="lg" color={THEME.gold} thickness="3px" />
<Text fontSize="sm" color={THEME.textSecondary}>
{message}
</Text>
</VStack>
</Center>
);
};
export default LoadingState;

View File

@@ -0,0 +1,60 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
// 股权结构 Tab Panel - 使用拆分后的子组件
import React from "react";
import { SimpleGrid, Box } from "@chakra-ui/react";
import { useShareholderData } from "../../hooks/useShareholderData";
import {
ActualControlCard,
ConcentrationCard,
ShareholdersTable,
} from "../../components/shareholder";
import TabPanelContainer from "@components/TabPanelContainer";
interface ShareholderPanelProps {
stockCode: string;
}
/**
* 股权结构面板
* 使用拆分后的子组件:
* - ActualControlCard: 实际控制人卡片
* - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
return (
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
{/* 实际控制人 + 股权集中度 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ActualControlCard actualControl={actualControl} />
</Box>
<Box>
<ConcentrationCard concentration={concentration} />
</Box>
</SimpleGrid>
{/* 十大股东 + 十大流通股东 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ShareholdersTable type="top" shareholders={topShareholders} />
</Box>
<Box>
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
</Box>
</SimpleGrid>
</TabPanelContainer>
);
};
export default ShareholderPanel;

View File

@@ -0,0 +1,11 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
// 组件导出
export { default as LoadingState } from "./LoadingState";
// TabPanelContainer 已提升为通用组件,从 @components/TabPanelContainer 导入
export { default as TabPanelContainer } from "@components/TabPanelContainer";
export { default as ShareholderPanel } from "./ShareholderPanel";
export { ManagementPanel } from "./management";
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
export { default as BranchesPanel } from "./BranchesPanel";
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx
// 管理层分类区块组件
import React, { memo } from "react";
import {
Box,
HStack,
Heading,
Badge,
Icon,
SimpleGrid,
} from "@chakra-ui/react";
import type { IconType } from "react-icons";
import { THEME } from "../../config";
import ManagementCard from "./ManagementCard";
import type { ManagementPerson, ManagementCategory } from "./types";
interface CategorySectionProps {
category: ManagementCategory;
people: ManagementPerson[];
icon: IconType;
color: string;
}
const CategorySection: React.FC<CategorySectionProps> = ({
category,
people,
icon,
color,
}) => {
if (people.length === 0) {
return null;
}
return (
<Box>
{/* 分类标题 */}
<HStack mb={4}>
<Icon as={icon} color={color} boxSize={5} />
<Heading size="sm" color={THEME.textPrimary}>
{category}
</Heading>
<Badge bg={THEME.gold} color="gray.900">
{people.length}
</Badge>
</HStack>
{/* 人员卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{people.map((person, idx) => (
<ManagementCard
key={`${person.name}-${idx}`}
person={person}
categoryColor={color}
/>
))}
</SimpleGrid>
</Box>
);
};
export default memo(CategorySection);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx
// 管理人员卡片组件
import React, { memo } from "react";
import {
HStack,
VStack,
Text,
Icon,
Card,
CardBody,
Avatar,
Tag,
} from "@chakra-ui/react";
import {
FaVenusMars,
FaGraduationCap,
FaPassport,
} from "react-icons/fa";
import { THEME } from "../../config";
import { formatDate } from "../../utils";
import type { ManagementPerson } from "./types";
interface ManagementCardProps {
person: ManagementPerson;
categoryColor: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }) => {
const currentYear = new Date().getFullYear();
const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null;
return (
<Card
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
>
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={categoryColor}
/>
<VStack align="start" spacing={1} flex={1}>
{/* 姓名和性别 */}
<HStack>
<Text fontWeight="bold" color={THEME.textPrimary}>
{person.name}
</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={person.gender === "男" ? "blue.400" : "pink.400"}
boxSize={3}
/>
)}
</HStack>
{/* 职位 */}
<Text fontSize="sm" color={THEME.goldLight}>
{person.position_name}
</Text>
{/* 标签:学历、年龄、国籍 */}
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
{person.education}
</Tag>
)}
{age && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{age}
</Tag>
)}
{person.nationality && person.nationality !== "中国" && (
<Tag size="sm" bg="orange.600" color="white">
<Icon as={FaPassport} mr={1} boxSize={3} />
{person.nationality}
</Tag>
)}
</HStack>
{/* 任职日期 */}
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(person.start_date)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
);
};
export default memo(ManagementCard);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
// 管理团队 Tab Panel重构版
import React, { useMemo } from "react";
import {
FaUserTie,
FaCrown,
FaEye,
FaUsers,
} from "react-icons/fa";
import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config";
import TabPanelContainer from "@components/TabPanelContainer";
import CategorySection from "./CategorySection";
import type {
ManagementPerson,
ManagementCategory,
CategorizedManagement,
CategoryConfig,
} from "./types";
interface ManagementPanelProps {
stockCode: string;
}
/**
* 分类配置映射
*/
const CATEGORY_CONFIG: Record<ManagementCategory, CategoryConfig> = {
: { icon: FaUserTie, color: THEME.gold },
: { icon: FaCrown, color: THEME.goldLight },
: { icon: FaEye, color: "green.400" },
: { icon: FaUsers, color: THEME.textSecondary },
};
/**
* 分类顺序
*/
const CATEGORY_ORDER: ManagementCategory[] = ["高管", "董事", "监事", "其他"];
/**
* 根据职位信息对管理人员进行分类
*/
const categorizeManagement = (management: ManagementPerson[]): CategorizedManagement => {
const categories: CategorizedManagement = {
: [],
: [],
: [],
: [],
};
management.forEach((person) => {
const positionCategory = person.position_category;
const positionName = person.position_name || "";
if (positionCategory === "高管" || positionName.includes("总")) {
categories["高管"].push(person);
} else if (positionCategory === "董事" || positionName.includes("董事")) {
categories["董事"].push(person);
} else if (positionCategory === "监事" || positionName.includes("监事")) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
const { management, loading } = useManagementData(stockCode);
// 使用 useMemo 缓存分类计算结果
const categorizedManagement = useMemo(
() => categorizeManagement(management as ManagementPerson[]),
[management]
);
return (
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
{CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category];
const people = categorizedManagement[category];
return (
<CategorySection
key={category}
category={category}
people={people}
icon={config.icon}
color={config.color}
/>
);
})}
</TabPanelContainer>
);
};
export default ManagementPanel;

View File

@@ -0,0 +1,7 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts
// 管理团队组件导出
export { default as ManagementPanel } from "./ManagementPanel";
export { default as ManagementCard } from "./ManagementCard";
export { default as CategorySection } from "./CategorySection";
export * from "./types";

View File

@@ -0,0 +1,36 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts
// 管理团队相关类型定义
import type { IconType } from "react-icons";
/**
* 管理人员信息
*/
export interface ManagementPerson {
name: string;
position_name?: string;
position_category?: string;
gender?: "男" | "女";
education?: string;
birth_year?: string;
nationality?: string;
start_date?: string;
}
/**
* 管理层分类
*/
export type ManagementCategory = "高管" | "董事" | "监事" | "其他";
/**
* 分类后的管理层数据
*/
export type CategorizedManagement = Record<ManagementCategory, ManagementPerson[]>;
/**
* 分类配置项
*/
export interface CategoryConfig {
icon: IconType;
color: string;
}

View File

@@ -0,0 +1,96 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts
// Tab 配置 + 黑金主题配置
import { IconType } from "react-icons";
import {
FaShareAlt,
FaUserTie,
FaSitemap,
FaInfoCircle,
} from "react-icons/fa";
// 主题类型定义
export interface Theme {
bg: string;
cardBg: string;
tableBg: string;
tableHoverBg: string;
gold: string;
goldLight: string;
textPrimary: string;
textSecondary: string;
border: string;
tabSelected: {
bg: string;
color: string;
};
tabUnselected: {
color: string;
};
}
// 黑金主题配置
export const THEME: Theme = {
bg: "gray.900",
cardBg: "gray.800",
tableBg: "gray.700",
tableHoverBg: "gray.600",
gold: "#D4AF37",
goldLight: "#F0D78C",
textPrimary: "white",
textSecondary: "gray.400",
border: "rgba(212, 175, 55, 0.3)",
tabSelected: {
bg: "#D4AF37",
color: "gray.900",
},
tabUnselected: {
color: "#D4AF37",
},
};
// Tab 配置类型
export interface TabConfig {
key: string;
name: string;
icon: IconType;
enabled: boolean;
}
// Tab 配置
export const TAB_CONFIG: TabConfig[] = [
{
key: "shareholder",
name: "股权结构",
icon: FaShareAlt,
enabled: true,
},
{
key: "management",
name: "管理团队",
icon: FaUserTie,
enabled: true,
},
{
key: "branches",
name: "分支机构",
icon: FaSitemap,
enabled: true,
},
{
key: "business",
name: "工商信息",
icon: FaInfoCircle,
enabled: true,
},
];
// 获取启用的 Tab 列表
export const getEnabledTabs = (enabledKeys?: string[]): TabConfig[] => {
if (!enabledKeys || enabledKeys.length === 0) {
return TAB_CONFIG.filter((tab) => tab.enabled);
}
return TAB_CONFIG.filter(
(tab) => tab.enabled && enabledKeys.includes(tab.key)
);
};

View File

@@ -0,0 +1,87 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
import {
ShareholderPanel,
ManagementPanel,
AnnouncementsPanel,
BranchesPanel,
BusinessInfoPanel,
} from "./components";
// Props 类型定义
export interface BasicInfoTabProps {
stockCode: string;
// 可配置项
enabledTabs?: string[]; // 指定显示哪些 Tab通过 key
defaultTabIndex?: number; // 默认选中 Tab
onTabChange?: (index: number, tabKey: string) => void;
}
// Tab 组件映射
const TAB_COMPONENTS: Record<string, React.FC<any>> = {
shareholder: ShareholderPanel,
management: ManagementPanel,
announcements: AnnouncementsPanel,
branches: BranchesPanel,
business: BusinessInfoPanel,
};
/**
* 构建 SubTabContainer 所需的 tabs 配置
*/
const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
const enabledTabs = getEnabledTabs(enabledKeys);
return enabledTabs.map((tab) => ({
key: tab.key,
name: tab.name,
icon: tab.icon,
component: TAB_COMPONENTS[tab.key],
}));
};
/**
* 基本信息 Tab 组件
*
* 特性:
* - 使用 SubTabContainer 通用组件
* - 可配置显示哪些 TabenabledTabs
* - 黑金主题
* - 懒加载
* - 支持 Tab 变更回调
*/
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
stockCode,
enabledTabs,
defaultTabIndex = 0,
onTabChange,
}) => {
// 构建 tabs 配置(缓存避免重复计算)
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"
/>
</CardBody>
</Card>
);
};
export default BasicInfoTab;
// 导出配置和工具,供外部使用
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
export * from "./utils";

View File

@@ -0,0 +1,52 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts
// 格式化工具函数
/**
* 格式化百分比
*/
export const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
/**
* 格式化数字(自动转换亿/万)
*/
export const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
};
/**
* 格式化股数(自动转换亿股/万股)
*/
export const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
/**
* 格式化日期(去掉时间部分)
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
// 导出工具对象(兼容旧代码)
export const formatUtils = {
formatPercentage,
formatNumber,
formatShares,
formatDate,
};

View File

@@ -0,0 +1,94 @@
/**
* 业务结构树形项组件
*
* 递归显示业务结构层级
* 使用位置:业务结构分析卡片
* 黑金主题风格
*/
import React from 'react';
import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/react';
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
import type { BusinessTreeItemProps } from '../types';
// 黑金主题配置
const THEME = {
bg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.5)',
};
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
// 获取营收显示
const getRevenueDisplay = (): string => {
const revenue = business.revenue || business.financial_metrics?.revenue;
const unit = business.revenue_unit;
if (revenue !== undefined && revenue !== null) {
return formatBusinessRevenue(revenue, unit);
}
return '-';
};
return (
<Box
ml={depth * 6}
p={3}
bg={THEME.bg}
borderLeft={depth > 0 ? '4px solid' : 'none'}
borderLeftColor={THEME.gold}
borderRadius="md"
mb={2}
_hover={{ shadow: 'md', bg: 'gray.600' }}
transition="all 0.2s"
>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
{business.business_name}
</Text>
{business.financial_metrics?.revenue_ratio &&
business.financial_metrics.revenue_ratio > 30 && (
<Badge bg={THEME.gold} color="gray.900" size="sm">
</Badge>
)}
</HStack>
<HStack spacing={4} flexWrap="wrap">
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.revenue_ratio)}
</Tag>
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.gross_margin)}
</Tag>
{business.growth_metrics?.revenue_growth !== undefined && (
<Tag
size="sm"
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
color="white"
>
<TagLabel>
: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
{formatPercentage(business.growth_metrics.revenue_growth)}
</TagLabel>
</Tag>
)}
</HStack>
</VStack>
<VStack align="end" spacing={0}>
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
{getRevenueDisplay()}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
</VStack>
</HStack>
</Box>
);
};
export default BusinessTreeItem;

View File

@@ -0,0 +1,24 @@
/**
* 免责声明组件
*
* 显示 AI 分析内容的免责声明提示
* 使用位置:深度分析各 Card 底部(共 6 处)
*/
import React from 'react';
import { Text } from '@chakra-ui/react';
const DisclaimerBox: React.FC = () => {
return (
<Text
mb={4}
color="gray.500"
fontSize="12px"
lineHeight="1.5"
>
AI模型基于新闻
</Text>
);
};
export default DisclaimerBox;

View File

@@ -0,0 +1,128 @@
/**
* 关键因素卡片组件
*
* 显示单个关键因素的详细信息
* 使用位置:关键因素 Accordion 内
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
VStack,
HStack,
Text,
Badge,
Tag,
Icon,
} from '@chakra-ui/react';
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
import type { KeyFactorCardProps, ImpactDirection } from '../types';
// 黑金主题样式常量
const THEME = {
cardBg: '#252D3A',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
/**
* 获取影响方向对应的颜色
*/
const getImpactColor = (direction?: ImpactDirection): string => {
const colorMap: Record<ImpactDirection, string> = {
positive: 'red',
negative: 'green',
neutral: 'gray',
mixed: 'yellow',
};
return colorMap[direction || 'neutral'] || 'gray';
};
/**
* 获取影响方向的中文标签
*/
const getImpactLabel = (direction?: ImpactDirection): string => {
const labelMap: Record<ImpactDirection, string> = {
positive: '正面',
negative: '负面',
neutral: '中性',
mixed: '混合',
};
return labelMap[direction || 'neutral'] || '中性';
};
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
const impactColor = getImpactColor(factor.impact_direction);
return (
<Card
bg={THEME.cardBg}
border="1px solid"
borderColor="whiteAlpha.100"
size="sm"
>
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
{factor.factor_name}
</Text>
<Badge
bg="transparent"
border="1px solid"
borderColor={`${impactColor}.400`}
color={`${impactColor}.400`}
size="sm"
>
{getImpactLabel(factor.impact_direction)}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
{factor.factor_value}
{factor.factor_unit && ` ${factor.factor_unit}`}
</Text>
{factor.year_on_year !== undefined && (
<Tag
size="sm"
bg="transparent"
border="1px solid"
borderColor={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
color={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
>
<Icon
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
mr={1}
boxSize={3}
/>
{Math.abs(factor.year_on_year)}%
</Tag>
)}
</HStack>
{factor.factor_desc && (
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
{factor.factor_desc}
</Text>
)}
<HStack justify="space-between">
<Text fontSize="xs" color={THEME.subtextColor}>
: {factor.impact_weight}
</Text>
{factor.report_period && (
<Text fontSize="xs" color={THEME.subtextColor}>
{factor.report_period}
</Text>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
export default KeyFactorCard;

View File

@@ -0,0 +1,170 @@
/**
* 产业链流程式导航组件
*
* 显示上游 → 核心 → 下游的流程式导航
* 带图标箭头连接符
*/
import React, { memo } from 'react';
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
import { FaArrowRight } from 'react-icons/fa';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textSecondary: 'gray.400',
upstream: {
active: 'orange.500',
activeBg: 'orange.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
core: {
active: 'blue.500',
activeBg: 'blue.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
downstream: {
active: 'green.500',
activeBg: 'green.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
};
export type TabType = 'upstream' | 'core' | 'downstream';
interface ProcessNavigationProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
upstreamCount: number;
coreCount: number;
downstreamCount: number;
}
interface NavItemProps {
label: string;
subtitle: string;
count: number;
isActive: boolean;
colorKey: 'upstream' | 'core' | 'downstream';
onClick: () => void;
}
const NavItem: React.FC<NavItemProps> = memo(({
label,
subtitle,
count,
isActive,
colorKey,
onClick,
}) => {
const colors = THEME[colorKey];
return (
<Box
px={4}
py={2}
borderRadius="lg"
cursor="pointer"
bg={isActive ? colors.activeBg : colors.inactiveBg}
borderWidth={2}
borderColor={isActive ? colors.active : 'gray.600'}
onClick={onClick}
transition="all 0.2s"
_hover={{
borderColor: colors.active,
transform: 'translateY(-2px)',
}}
>
<VStack spacing={1} align="center">
<HStack spacing={2}>
<Text
fontWeight={isActive ? 'bold' : 'medium'}
color={isActive ? colors.active : colors.inactive}
fontSize="sm"
>
{label}
</Text>
<Badge
bg={isActive ? colors.active : 'gray.600'}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{count}
</Badge>
</HStack>
<Text
fontSize="xs"
color={THEME.textSecondary}
>
{subtitle}
</Text>
</VStack>
</Box>
);
});
NavItem.displayName = 'NavItem';
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
activeTab,
onTabChange,
upstreamCount,
coreCount,
downstreamCount,
}) => {
return (
<HStack
spacing={2}
flexWrap="wrap"
gap={2}
>
<NavItem
label="上游供应链"
subtitle="原材料与供应商"
count={upstreamCount}
isActive={activeTab === 'upstream'}
colorKey="upstream"
onClick={() => onTabChange('upstream')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="核心企业"
subtitle="公司主体与产品"
count={coreCount}
isActive={activeTab === 'core'}
colorKey="core"
onClick={() => onTabChange('core')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="下游客户"
subtitle="客户与终端市场"
count={downstreamCount}
isActive={activeTab === 'downstream'}
colorKey="downstream"
onClick={() => onTabChange('downstream')}
/>
</HStack>
);
});
ProcessNavigation.displayName = 'ProcessNavigation';
export default ProcessNavigation;

View File

@@ -0,0 +1,51 @@
/**
* 评分进度条组件
*
* 显示带图标的评分进度条
* 使用位置:竞争力分析区域(共 8 处)
*/
import React from 'react';
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
import type { ScoreBarProps } from '../types';
/**
* 根据分数百分比获取颜色方案
*/
const getColorScheme = (percentage: number): string => {
if (percentage >= 80) return 'purple';
if (percentage >= 60) return 'blue';
if (percentage >= 40) return 'yellow';
return 'orange';
};
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
const percentage = ((score || 0) / 100) * 100;
const colorScheme = getColorScheme(percentage);
return (
<Box>
<HStack justify="space-between" mb={1}>
<HStack>
{icon && (
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
)}
<Text fontSize="sm" fontWeight="medium">
{label}
</Text>
</HStack>
<Badge colorScheme={colorScheme}>{score || 0}</Badge>
</HStack>
<Progress
value={percentage}
size="sm"
colorScheme={colorScheme}
borderRadius="full"
hasStripe
isAnimated
/>
</Box>
);
};
export default ScoreBar;

View File

@@ -0,0 +1,151 @@
/**
* 产业链筛选栏组件
*
* 提供类型筛选、重要度筛选和视图切换功能
*/
import React, { memo } from 'react';
import {
HStack,
Select,
Tabs,
TabList,
Tab,
} from '@chakra-ui/react';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
inputBg: 'gray.700',
inputBorder: 'gray.600',
};
export type ViewMode = 'hierarchy' | 'flow';
// 节点类型选项
const TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'company', label: '公司' },
{ value: 'supplier', label: '供应商' },
{ value: 'customer', label: '客户' },
{ value: 'regulator', label: '监管机构' },
{ value: 'product', label: '产品' },
{ value: 'service', label: '服务' },
{ value: 'channel', label: '渠道' },
{ value: 'raw_material', label: '原材料' },
{ value: 'end_user', label: '终端用户' },
];
// 重要度选项
const IMPORTANCE_OPTIONS = [
{ value: 'all', label: '全部重要度' },
{ value: 'high', label: '高 (≥80)' },
{ value: 'medium', label: '中 (50-79)' },
{ value: 'low', label: '低 (<50)' },
];
interface ValueChainFilterBarProps {
typeFilter: string;
onTypeChange: (value: string) => void;
importanceFilter: string;
onImportanceChange: (value: string) => void;
viewMode: ViewMode;
onViewModeChange: (value: ViewMode) => void;
}
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
typeFilter,
onTypeChange,
importanceFilter,
onImportanceChange,
viewMode,
onViewModeChange,
}) => {
return (
<HStack
spacing={3}
flexWrap="wrap"
gap={3}
>
{/* 左侧筛选区 */}
{/* <HStack spacing={3}>
<Select
value={typeFilter}
onChange={(e) => onTypeChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
<Select
value={importanceFilter}
onChange={(e) => onImportanceChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{IMPORTANCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
</HStack> */}
{/* 右侧视图切换 */}
<Tabs
index={viewMode === 'hierarchy' ? 0 : 1}
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
variant="soft-rounded"
size="sm"
>
<TabList>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
</TabList>
</Tabs>
</HStack>
);
});
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
export default ValueChainFilterBar;

View File

@@ -0,0 +1,14 @@
/**
* 原子组件导出
*
* DeepAnalysisTab 内部使用的基础 UI 组件
*/
export { default as DisclaimerBox } from './DisclaimerBox';
export { default as ScoreBar } from './ScoreBar';
export { default as BusinessTreeItem } from './BusinessTreeItem';
export { default as KeyFactorCard } from './KeyFactorCard';
export { default as ProcessNavigation } from './ProcessNavigation';
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
export type { TabType } from './ProcessNavigation';
export type { ViewMode } from './ValueChainFilterBar';

View File

@@ -0,0 +1,169 @@
/**
* 业务板块详情卡片
*
* 显示公司各业务板块的详细信息
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
SimpleGrid,
Button,
} from '@chakra-ui/react';
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
import type { BusinessSegment } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
innerCardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessSegmentsCardProps {
businessSegments: BusinessSegment[];
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
cardBg?: string;
}
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
businessSegments,
expandedSegments,
onToggleSegment,
}) => {
if (!businessSegments || businessSegments.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaIndustry} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} </Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{businessSegments.map((segment, idx) => {
const isExpanded = expandedSegments[idx];
return (
<Card key={idx} bg={THEME.innerCardBg}>
<CardBody px={2}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
{segment.segment_name}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
}
onClick={() => onToggleSegment(idx)}
color={THEME.gold}
_hover={{ bg: 'gray.600' }}
>
{isExpanded ? '折叠' : '展开'}
</Button>
</HStack>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 3}
>
{segment.segment_description || '暂无描述'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 2}
>
{segment.competitive_position || '暂无数据'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
color={THEME.goldLight}
>
{segment.future_potential || '暂无数据'}
</Text>
</Box>
{isExpanded && segment.key_products && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text fontSize="sm" color="green.300">
{segment.key_products}
</Text>
</Box>
)}
{isExpanded && segment.market_share !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg="purple.600" color="white" fontSize="sm">
{segment.market_share}%
</Badge>
</Box>
)}
{isExpanded && segment.revenue_contribution !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
{segment.revenue_contribution}%
</Badge>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
</CardBody>
</Card>
);
};
export default BusinessSegmentsCard;

View File

@@ -0,0 +1,65 @@
/**
* 业务结构分析卡片
*
* 显示公司业务结构树形图
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Icon,
} from '@chakra-ui/react';
import { FaChartPie } from 'react-icons/fa';
import { BusinessTreeItem } from '../atoms';
import type { BusinessStructure } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
textPrimary: '#D4AF37',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessStructureCardProps {
businessStructure: BusinessStructure[];
cardBg?: string;
}
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
businessStructure,
}) => {
if (!businessStructure || businessStructure.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaChartPie} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
</HStack>
</CardHeader>
<CardBody px={0}>
<VStack spacing={3} align="stretch">
{businessStructure.map((business, idx) => (
<BusinessTreeItem
key={idx}
business={business}
depth={business.business_level - 1}
/>
))}
</VStack>
</CardBody>
</Card>
);
};
export default BusinessStructureCard;

View File

@@ -0,0 +1,295 @@
/**
* 竞争地位分析卡片
*
* 显示竞争力评分、雷达图和竞争分析
* 包含行业排名弹窗功能
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Tag,
TagLabel,
Grid,
GridItem,
Box,
Icon,
Divider,
SimpleGrid,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import {
FaTrophy,
FaCog,
FaStar,
FaChartLine,
FaDollarSign,
FaFlask,
FaShieldAlt,
FaRocket,
FaUsers,
FaExternalLinkAlt,
} from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { ScoreBar } from '../atoms';
import { getRadarChartOption } from '../utils/chartOptions';
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 = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
const CHART_STYLE = { height: '320px' } as const;
interface CompetitiveAnalysisCardProps {
comprehensiveData: ComprehensiveData;
industryRankData?: IndustryRankData[];
}
// 竞争对手标签组件
interface CompetitorTagsProps {
competitors: string[];
}
const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
<Box mb={4}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="yellow.500">
</Text>
<HStack spacing={2} flexWrap="wrap">
{competitors.map((competitor, idx) => (
<Tag
key={idx}
size="md"
variant="outline"
borderColor="yellow.600"
color="yellow.500"
borderRadius="full"
>
<Icon as={FaUsers} mr={1} />
<TagLabel>{competitor}</TagLabel>
</Tag>
))}
</HStack>
</Box>
));
CompetitorTags.displayName = 'CompetitorTags';
// 评分区域组件
interface ScoreSectionProps {
scores: CompetitivePosition['scores'];
}
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
<VStack spacing={4} align="stretch">
<ScoreBar label="市场地位" score={scores?.market_position} icon={FaTrophy} />
<ScoreBar label="技术实力" score={scores?.technology} icon={FaCog} />
<ScoreBar label="品牌价值" score={scores?.brand} icon={FaStar} />
<ScoreBar label="运营效率" score={scores?.operation} icon={FaChartLine} />
<ScoreBar label="财务健康" score={scores?.finance} icon={FaDollarSign} />
<ScoreBar label="创新能力" score={scores?.innovation} icon={FaFlask} />
<ScoreBar label="风险控制" score={scores?.risk} icon={FaShieldAlt} />
<ScoreBar label="成长潜力" score={scores?.growth} icon={FaRocket} />
</VStack>
));
ScoreSection.displayName = 'ScoreSection';
// 竞争优劣势组件
interface AdvantagesSectionProps {
advantages?: string;
disadvantages?: string;
}
const AdvantagesSection = memo<AdvantagesSectionProps>(
({ advantages, disadvantages }) => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
</Text>
<Text fontSize="sm" color="white">
{advantages || '暂无数据'}
</Text>
</Box>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
</Text>
<Text fontSize="sm" color="white">
{disadvantages || '暂无数据'}
</Text>
</Box>
</SimpleGrid>
)
);
AdvantagesSection.displayName = 'AdvantagesSection';
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
({ comprehensiveData, industryRankData }) => {
const competitivePosition = comprehensiveData.competitive_position;
const { isOpen, onOpen, onClose } = useDisclosure();
if (!competitivePosition) return null;
// 缓存雷达图配置
const radarOption = useMemo(
() => getRadarChartOption(comprehensiveData),
[comprehensiveData]
);
// 缓存竞争对手列表
const competitors = useMemo(
() =>
competitivePosition.analysis?.main_competitors
?.split(',')
.map((c) => c.trim()) || [],
[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"
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>
<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)"
/>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
}
);
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
export default CompetitiveAnalysisCard;

View File

@@ -0,0 +1,54 @@
/**
* 投资亮点卡片组件
*/
import React, { memo } from 'react';
import { Box, HStack, VStack, Icon, Text } from '@chakra-ui/react';
import { FaUsers } from 'react-icons/fa';
import { THEME, ICON_MAP, HIGHLIGHT_HOVER_STYLES } from '../theme';
import type { InvestmentHighlightItem } from '../../../types';
interface HighlightCardProps {
highlight: InvestmentHighlightItem;
}
export const HighlightCard = memo<HighlightCardProps>(({ highlight }) => {
const IconComponent = ICON_MAP[highlight.icon] || FaUsers;
return (
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
{...HIGHLIGHT_HOVER_STYLES}
transition="border-color 0.2s"
>
<HStack spacing={3} align="flex-start">
<Box
p={2}
bg="whiteAlpha.100"
borderRadius="md"
color={THEME.light.titleColor}
>
<Icon as={IconComponent} boxSize={4} />
</Box>
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{highlight.title}
</Text>
<Text
fontSize="xs"
color={THEME.light.subtextColor}
lineHeight="tall"
>
{highlight.description}
</Text>
</VStack>
</HStack>
</Box>
);
});
HighlightCard.displayName = 'HighlightCard';

View File

@@ -0,0 +1,47 @@
/**
* 商业模式板块组件
*/
import React, { memo } from 'react';
import { Box, VStack, HStack, Text, Tag, Divider } from '@chakra-ui/react';
import { THEME } from '../theme';
import type { BusinessModelSection } from '../../../types';
interface ModelBlockProps {
section: BusinessModelSection;
isLast?: boolean;
}
export const ModelBlock = memo<ModelBlockProps>(({ section, isLast }) => (
<Box>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{section.title}
</Text>
<Text fontSize="xs" color={THEME.light.subtextColor} lineHeight="tall">
{section.description}
</Text>
{section.tags && section.tags.length > 0 && (
<HStack spacing={2} flexWrap="wrap" mt={1}>
{section.tags.map((tag, idx) => (
<Tag
key={idx}
size="sm"
bg={THEME.light.tagBg}
color={THEME.light.tagColor}
borderRadius="full"
px={3}
py={1}
fontSize="xs"
>
{tag}
</Tag>
))}
</HStack>
)}
</VStack>
{!isLast && <Divider my={4} borderColor="whiteAlpha.100" />}
</Box>
));
ModelBlock.displayName = 'ModelBlock';

View File

@@ -0,0 +1,27 @@
/**
* 区域标题组件
*/
import React, { memo } from 'react';
import { HStack, Icon, Text } from '@chakra-ui/react';
import type { IconType } from 'react-icons';
import { THEME } from '../theme';
interface SectionHeaderProps {
icon: IconType;
title: string;
color?: string;
}
export const SectionHeader = memo<SectionHeaderProps>(
({ icon, title, color = THEME.dark.titleColor }) => (
<HStack spacing={2} mb={4}>
<Icon as={icon} color={color} boxSize={4} />
<Text fontWeight="bold" color={color} fontSize="md">
{title}
</Text>
</HStack>
)
);
SectionHeader.displayName = 'SectionHeader';

View File

@@ -0,0 +1,7 @@
/**
* CorePositioningCard 原子组件统一导出
*/
export { SectionHeader } from './SectionHeader';
export { HighlightCard } from './HighlightCard';
export { ModelBlock } from './ModelBlock';

View File

@@ -0,0 +1,204 @@
/**
* 核心定位卡片
*
* 显示公司的核心定位、投资亮点和商业模式
* 黑金主题设计
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
VStack,
Text,
Box,
Grid,
GridItem,
} from '@chakra-ui/react';
import { FaCrown, FaStar, FaBriefcase } from 'react-icons/fa';
import type {
QualitativeAnalysis,
InvestmentHighlightItem,
} from '../../types';
import {
THEME,
CARD_STYLES,
GRID_COLUMNS,
BORDER_RIGHT_RESPONSIVE,
} from './theme';
import { SectionHeader, HighlightCard, ModelBlock } from './atoms';
// ==================== 主组件 ====================
interface CorePositioningCardProps {
qualitativeAnalysis: QualitativeAnalysis;
cardBg?: string;
}
const CorePositioningCard: React.FC<CorePositioningCardProps> = memo(
({ qualitativeAnalysis }) => {
const corePositioning = qualitativeAnalysis.core_positioning;
// 判断是否有结构化数据
const hasStructuredData = useMemo(
() =>
!!(
corePositioning?.features?.length ||
(Array.isArray(corePositioning?.investment_highlights) &&
corePositioning.investment_highlights.length > 0) ||
corePositioning?.business_model_sections?.length
),
[corePositioning]
);
// 如果没有结构化数据,使用旧的文本格式渲染
if (!hasStructuredData) {
return (
<Card {...CARD_STYLES}>
<CardBody>
<VStack spacing={4} align="stretch">
<SectionHeader icon={FaCrown} title="核心定位" />
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
<Grid templateColumns={GRID_COLUMNS.twoColumnMd} gap={4}>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaStar} title="投资亮点" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.investment_highlights_text ||
(typeof corePositioning?.investment_highlights === 'string'
? corePositioning.investment_highlights
: '暂无数据')}
</Text>
</Box>
</GridItem>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.business_model_desc || '暂无数据'}
</Text>
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
// 结构化数据渲染 - 缓存数组计算
const highlights = useMemo(
() =>
(Array.isArray(corePositioning?.investment_highlights)
? corePositioning.investment_highlights
: []) as InvestmentHighlightItem[],
[corePositioning?.investment_highlights]
);
const businessSections = useMemo(
() => corePositioning?.business_model_sections || [],
[corePositioning?.business_model_sections]
);
return (
<Card {...CARD_STYLES}>
<CardBody p={0}>
<VStack spacing={0} align="stretch">
{/* 核心定位区域(深色背景) */}
<Box p={6} bg={THEME.dark.bg}>
<SectionHeader icon={FaCrown} title="核心定位" />
{/* 一句话介绍 */}
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
</Box>
{/* 投资亮点 + 商业模式区域 */}
<Grid templateColumns={GRID_COLUMNS.twoColumn} bg={THEME.light.bg}>
{/* 投资亮点区域 */}
<GridItem
p={6}
borderRight={BORDER_RIGHT_RESPONSIVE}
borderColor="whiteAlpha.100"
>
<SectionHeader icon={FaStar} title="投资亮点" />
<VStack spacing={3} align="stretch">
{highlights.length > 0 ? (
highlights.map((highlight, idx) => (
<HighlightCard key={idx} highlight={highlight} />
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</VStack>
</GridItem>
{/* 商业模式区域 */}
<GridItem p={6}>
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
>
{businessSections.length > 0 ? (
businessSections.map((section, idx) => (
<ModelBlock
key={idx}
section={section}
isLast={idx === businessSections.length - 1}
/>
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
);
CorePositioningCard.displayName = 'CorePositioningCard';
export default CorePositioningCard;

View File

@@ -0,0 +1,83 @@
/**
* CorePositioningCard 主题和样式常量
*/
import {
FaUniversity,
FaFire,
FaUsers,
FaChartLine,
FaMicrochip,
FaShieldAlt,
} from 'react-icons/fa';
import type { IconType } from 'react-icons';
// ==================== 主题常量 ====================
export const THEME = {
// 深色背景区域(核心定位)
dark: {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
},
// 浅色背景区域(投资亮点/商业模式)
light: {
bg: '#1E2530',
cardBg: '#252D3A',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
tagBg: 'rgba(201, 169, 97, 0.15)',
tagColor: '#C9A961',
},
} as const;
// ==================== 图标映射 ====================
export const ICON_MAP: Record<string, IconType> = {
bank: FaUniversity,
fire: FaFire,
users: FaUsers,
'trending-up': FaChartLine,
cpu: FaMicrochip,
'shield-check': FaShieldAlt,
};
// ==================== 样式常量 ====================
// 卡片通用样式(含顶部金色边框)
export const CARD_STYLES = {
bg: THEME.dark.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.dark.borderGradient,
},
} as const;
// HighlightCard hover 样式
export const HIGHLIGHT_HOVER_STYLES = {
_hover: { borderColor: 'whiteAlpha.200' },
} as const;
// 响应式布局常量
export const GRID_COLUMNS = {
twoColumn: { base: '1fr', lg: 'repeat(2, 1fr)' },
twoColumnMd: { base: '1fr', md: 'repeat(2, 1fr)' },
} as const;
export const BORDER_RIGHT_RESPONSIVE = { lg: '1px solid' } as const;

View File

@@ -0,0 +1,124 @@
/**
* 关键因素卡片
*
* 显示影响公司的关键因素列表
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FaBalanceScale } from 'react-icons/fa';
import { KeyFactorCard } from '../atoms';
import type { KeyFactors } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface KeyFactorsCardProps {
keyFactors: KeyFactors;
cardBg?: string;
}
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaBalanceScale} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
</Heading>
<Badge
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
>
{keyFactors.total_factors}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Accordion allowMultiple>
{keyFactors.categories.map((category, idx) => (
<AccordionItem key={idx} border="none">
<AccordionButton
bg={THEME.cardBg}
borderRadius="md"
mb={2}
_hover={{ bg: 'whiteAlpha.100' }}
>
<Box flex="1" textAlign="left">
<HStack>
<Text fontWeight="medium" color={THEME.textColor}>
{category.category_name}
</Text>
<Badge
bg="whiteAlpha.100"
color={THEME.subtextColor}
size="sm"
>
{category.factors.length}
</Badge>
</HStack>
</Box>
<AccordionIcon color={THEME.subtextColor} />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={3} align="stretch">
{category.factors.map((factor, fidx) => (
<KeyFactorCard key={fidx} factor={factor} />
))}
</VStack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
);
};
export default KeyFactorsCard;

View File

@@ -0,0 +1,133 @@
/**
* 战略分析卡片
*
* 显示公司战略方向和战略举措
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Box,
Icon,
Grid,
GridItem,
Center,
} from '@chakra-ui/react';
import { FaRocket, FaChartBar } from 'react-icons/fa';
import type { Strategy } from '../types';
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const EMPTY_BOX_STYLES = {
border: '1px dashed',
borderColor: 'yellow.600',
borderRadius: 'md',
py: 12,
} as const;
const GRID_RESPONSIVE_COLSPAN = { base: 2, md: 1 } as const;
interface StrategyAnalysisCardProps {
strategy: Strategy;
cardBg?: string;
}
// 空状态组件 - 独立 memo 避免重复渲染
const EmptyState = memo(() => (
<Box {...EMPTY_BOX_STYLES}>
<Center>
<VStack spacing={3}>
<Icon as={FaChartBar} boxSize={10} color="yellow.600" />
<Text fontWeight="medium"></Text>
<Text fontSize="sm" color="gray.500">
</Text>
</VStack>
</Center>
</Box>
));
EmptyState.displayName = 'StrategyEmptyState';
// 内容项组件 - 复用结构
interface ContentItemProps {
title: string;
content: string;
}
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
{title}
</Text>
<Text fontSize="sm" color="white">
{content}
</Text>
</VStack>
));
ContentItem.displayName = 'StrategyContentItem';
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
({ strategy }) => {
// 缓存数据检测结果
const hasData = useMemo(
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
[strategy?.strategy_description, strategy?.strategic_initiatives]
);
return (
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaRocket} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
</HStack>
</CardHeader>
<CardBody>
{!hasData ? (
<EmptyState />
) : (
<Box {...CONTENT_BOX_STYLES}>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略方向"
content={strategy.strategy_description || '暂无数据'}
/>
</GridItem>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略举措"
content={strategy.strategic_initiatives || '暂无数据'}
/>
</GridItem>
</Grid>
</Box>
)}
</CardBody>
</Card>
);
}
);
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
export default StrategyAnalysisCard;

View File

@@ -0,0 +1,95 @@
/**
* 发展时间线卡片
*
* 显示公司发展历程时间线
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Heading,
Badge,
Box,
Icon,
} from '@chakra-ui/react';
import { FaHistory } from 'react-icons/fa';
import TimelineComponent from '../organisms/TimelineComponent';
import type { DevelopmentTimeline } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface TimelineCardProps {
developmentTimeline: DevelopmentTimeline;
cardBg?: string;
}
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaHistory} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
线
</Heading>
<HStack spacing={1}>
<Badge
bg="transparent"
border="1px solid"
borderColor="red.400"
color="red.400"
>
{developmentTimeline.statistics?.positive_events || 0}
</Badge>
<Badge
bg="transparent"
border="1px solid"
borderColor="green.400"
color="green.400"
>
{developmentTimeline.statistics?.negative_events || 0}
</Badge>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<Box maxH="600px" overflowY="auto" pr={2}>
<TimelineComponent events={developmentTimeline.events} />
</Box>
</CardBody>
</Card>
);
};
export default TimelineCard;

View File

@@ -0,0 +1,220 @@
/**
* 产业链分析卡片
*
* 显示产业链层级视图和流向关系
* 黑金主题风格 + 流程式导航
*/
import React, { useState, useMemo, memo } from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Text,
Heading,
Badge,
Icon,
SimpleGrid,
Center,
Box,
Flex,
} from '@chakra-ui/react';
import { FaNetworkWired } from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import {
ProcessNavigation,
ValueChainFilterBar,
} from '../atoms';
import type { TabType, ViewMode } from '../atoms';
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
import { getSankeyChartOption } from '../utils/chartOptions';
import type { ValueChainData, ValueChainNode } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
};
interface ValueChainCardProps {
valueChainData: ValueChainData;
companyName?: string;
cardBg?: string;
}
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
valueChainData,
companyName = '目标公司',
}) => {
// 状态管理
const [activeTab, setActiveTab] = useState<TabType>('upstream');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [importanceFilter, setImportanceFilter] = useState<string>('all');
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
// 解析节点数据
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
// 获取上游节点
const upstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_-2'] || []),
...(nodesByLevel?.['level_-1'] || []),
], [nodesByLevel]);
// 获取核心节点
const coreNodes = useMemo(() =>
nodesByLevel?.['level_0'] || [],
[nodesByLevel]);
// 获取下游节点
const downstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_1'] || []),
...(nodesByLevel?.['level_2'] || []),
], [nodesByLevel]);
// 计算总节点数
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
// 根据 activeTab 获取当前节点
const currentNodes = useMemo(() => {
switch (activeTab) {
case 'upstream':
return upstreamNodes;
case 'core':
return coreNodes;
case 'downstream':
return downstreamNodes;
default:
return [];
}
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
// 筛选节点
const filteredNodes = useMemo(() => {
let nodes = [...currentNodes];
// 类型筛选
if (typeFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
}
// 重要度筛选
if (importanceFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => {
const score = n.importance_score || 0;
switch (importanceFilter) {
case 'high':
return score >= 80;
case 'medium':
return score >= 50 && score < 80;
case 'low':
return score < 50;
default:
return true;
}
});
}
return nodes;
}, [currentNodes, typeFilter, importanceFilter]);
// Sankey 图配置
const sankeyOption = useMemo(() =>
getSankeyChartOption(valueChainData),
[valueChainData]);
return (
<Card bg={THEME.cardBg} shadow="md">
{/* 头部区域 */}
<CardHeader py={0}>
<HStack flexWrap="wrap" gap={0}>
<Icon as={FaNetworkWired} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}>
</Heading>
<Text color={THEME.textSecondary} fontSize="sm">
| {companyName}
</Text>
<Badge bg={THEME.gold} color="gray.900">
{totalNodes}
</Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
<Flex
borderBottom="1px solid"
borderColor="gray.700"
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
{viewMode === 'hierarchy' && (
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
)}
{/* 右侧:筛选与视图切换 - 始终靠右 */}
<Box ml="auto">
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
</Flex>
{/* 内容区域 */}
<Box px={0} pt={4}>
{viewMode === 'hierarchy' ? (
filteredNodes.length > 0 ? (
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{filteredNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={node.node_level}
/>
))}
</SimpleGrid>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)
) : sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="dark"
/>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)}
</Box>
</CardBody>
</Card>
);
});
ValueChainCard.displayName = 'ValueChainCard';
export default ValueChainCard;

View File

@@ -0,0 +1,14 @@
/**
* Card 子组件导出
*
* DeepAnalysisTab 的各个区块组件
*/
export { default as CorePositioningCard } from './CorePositioningCard';
export { default as CompetitiveAnalysisCard } from './CompetitiveAnalysisCard';
export { default as BusinessStructureCard } from './BusinessStructureCard';
export { default as ValueChainCard } from './ValueChainCard';
export { default as KeyFactorsCard } from './KeyFactorsCard';
export { default as TimelineCard } from './TimelineCard';
export { default as BusinessSegmentsCard } from './BusinessSegmentsCard';
export { default as StrategyAnalysisCard } from './StrategyAnalysisCard';

View File

@@ -0,0 +1,108 @@
/**
* 深度分析 Tab 主组件
*
* 使用 SubTabContainer 二级导航组件,分为 4 个子 Tab
* 1. 战略分析 - 核心定位 + 战略分析 + 竞争地位
* 2. 业务结构 - 业务结构树 + 业务板块详情
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
* 4. 发展历程 - 关键因素 + 时间线
*
* 支持懒加载:通过 activeTab 和 onTabChange 实现按需加载数据
*/
import React, { useMemo } from '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';
// 主题配置(与 BasicInfoTab 保持一致)
const THEME = {
cardBg: 'gray.900',
border: 'rgba(212, 175, 55, 0.3)',
};
/**
* Tab 配置
*/
const DEEP_ANALYSIS_TABS: SubTabConfig[] = [
{ key: 'strategy', name: '战略分析', icon: FaBrain, component: StrategyTab },
{ key: 'business', name: '业务结构', icon: FaBuilding, component: BusinessTab },
{ key: 'valueChain', name: '产业链', icon: FaLink, component: ValueChainTab },
{ key: 'development', name: '发展历程', icon: FaHistory, component: DevelopmentTab },
];
/**
* Tab key 到 index 的映射
*/
const TAB_KEY_TO_INDEX: Record<DeepAnalysisTabKey, number> = {
strategy: 0,
business: 1,
valueChain: 2,
development: 3,
};
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
loading,
cardBg,
expandedSegments,
onToggleSegment,
activeTab,
onTabChange,
}) => {
// 计算当前 Tab 索引(受控模式)
const currentIndex = useMemo(() => {
if (activeTab) {
return TAB_KEY_TO_INDEX[activeTab] ?? 0;
}
return undefined; // 非受控模式
}, [activeTab]);
// 加载状态
if (loading) {
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={DEEP_ANALYSIS_TABS}
index={currentIndex}
onTabChange={onTabChange}
componentProps={{}}
themePreset="blackGold"
/>
<LoadingState message="加载数据中..." height="200px" />
</CardBody>
</Card>
);
}
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={DEEP_ANALYSIS_TABS}
index={currentIndex}
onTabChange={onTabChange}
componentProps={{
comprehensiveData,
valueChainData,
keyFactorsData,
industryRankData,
cardBg,
expandedSegments,
onToggleSegment,
}}
themePreset="blackGold"
/>
</CardBody>
</Card>
);
};
export default DeepAnalysisTab;

View File

@@ -0,0 +1,136 @@
/**
* 事件详情模态框组件
*
* 显示时间线事件的详细信息
*/
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Text,
Badge,
Box,
Progress,
Icon,
Button,
} from '@chakra-ui/react';
import { FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
import type { TimelineEvent } from '../../types';
interface EventDetailModalProps {
isOpen: boolean;
onClose: () => void;
event: TimelineEvent | null;
}
const EventDetailModal: React.FC<EventDetailModalProps> = ({
isOpen,
onClose,
event,
}) => {
if (!event) return null;
const isPositive = event.impact_metrics?.is_positive;
const impactScore = event.impact_metrics?.impact_score || 0;
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack>
<Icon
as={isPositive ? FaCheckCircle : FaExclamationCircle}
color={isPositive ? 'red.500' : 'green.500'}
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text>{event.event_title}</Text>
<HStack>
<Badge colorScheme={isPositive ? 'red' : 'green'}>
{event.event_type}
</Badge>
<Text fontSize="sm" color="gray.500">
{event.event_date}
</Text>
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6">
{event.event_desc}
</Text>
</Box>
{event.related_info?.financial_impact && (
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6" color="blue.600">
{event.related_info.financial_impact}
</Text>
</Box>
)}
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<HStack spacing={4}>
<VStack spacing={1}>
<Text fontSize="xs" color="gray.500">
</Text>
<Progress
value={impactScore}
size="lg"
width="120px"
colorScheme={impactScore > 70 ? 'red' : 'orange'}
hasStripe
isAnimated
/>
<Text fontSize="sm" fontWeight="bold">
{impactScore}/100
</Text>
</VStack>
<VStack>
<Badge
size="lg"
colorScheme={isPositive ? 'red' : 'green'}
px={3}
py={1}
>
{isPositive ? '正面影响' : '负面影响'}
</Badge>
</VStack>
</HStack>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default EventDetailModal;

View File

@@ -0,0 +1,178 @@
/**
* 时间线组件
*
* 显示公司发展事件时间线
*/
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Icon,
Progress,
Circle,
Fade,
useDisclosure,
} from '@chakra-ui/react';
import {
FaCalendarAlt,
FaArrowUp,
FaArrowDown,
} from 'react-icons/fa';
import EventDetailModal from './EventDetailModal';
import type { TimelineComponentProps, TimelineEvent } from '../../types';
const TimelineComponent: React.FC<TimelineComponentProps> = ({ events }) => {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
// 背景颜色
const positiveBgColor = 'red.50';
const negativeBgColor = 'green.50';
const handleEventClick = (event: TimelineEvent) => {
setSelectedEvent(event);
onOpen();
};
return (
<>
<Box position="relative" pl={8}>
{/* 时间线轴 */}
<Box
position="absolute"
left="15px"
top="20px"
bottom="20px"
width="2px"
bg="gray.300"
/>
<VStack align="stretch" spacing={6}>
{events.map((event, idx) => {
const isPositive = event.impact_metrics?.is_positive;
const iconColor = isPositive ? 'red.500' : 'green.500';
const bgColor = isPositive ? positiveBgColor : negativeBgColor;
return (
<Fade in={true} key={idx}>
<Box position="relative">
{/* 时间点圆圈 */}
<Circle
size="30px"
bg={iconColor}
position="absolute"
left="-15px"
top="20px"
zIndex={2}
border="3px solid white"
shadow="md"
>
<Icon
as={isPositive ? FaArrowUp : FaArrowDown}
color="white"
boxSize={3}
/>
</Circle>
{/* 连接线 */}
<Box
position="absolute"
left="15px"
top="35px"
width="20px"
height="2px"
bg="gray.300"
/>
{/* 事件卡片 */}
<Card
ml={10}
bg={bgColor}
cursor="pointer"
onClick={() => handleEventClick(event)}
_hover={{ shadow: 'lg', transform: 'translateX(4px)' }}
transition="all 0.3s ease"
>
<CardBody p={4}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="sm">
{event.event_title}
</Text>
<HStack spacing={2}>
<Icon
as={FaCalendarAlt}
boxSize={3}
color="gray.500"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="medium"
>
{event.event_date}
</Text>
</HStack>
</VStack>
<Badge
colorScheme={isPositive ? 'red' : 'green'}
size="sm"
>
{event.event_type}
</Badge>
</HStack>
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{event.event_desc}
</Text>
<HStack>
<Text fontSize="xs" color="gray.500">
:
</Text>
<Progress
value={event.impact_metrics?.impact_score}
size="xs"
width="60px"
colorScheme={
(event.impact_metrics?.impact_score || 0) > 70
? 'red'
: 'orange'
}
borderRadius="full"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="bold"
>
{event.impact_metrics?.impact_score || 0}
</Text>
</HStack>
</VStack>
</CardBody>
</Card>
</Box>
</Fade>
);
})}
</VStack>
</Box>
<EventDetailModal
isOpen={isOpen}
onClose={onClose}
event={selectedEvent}
/>
</>
);
};
export default TimelineComponent;

View File

@@ -0,0 +1,346 @@
/**
* 相关公司模态框组件
*
* 显示产业链节点的相关上市公司列表
*/
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
Icon,
IconButton,
Center,
Spinner,
Divider,
SimpleGrid,
Box,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Progress,
Tooltip,
Button,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import {
FaBuilding,
FaHandshake,
FaUserTie,
FaIndustry,
FaCog,
FaNetworkWired,
FaFlask,
FaStar,
FaArrowRight,
FaArrowLeft,
} from 'react-icons/fa';
import type { ValueChainNode, RelatedCompany } from '../../types';
interface RelatedCompaniesModalProps {
isOpen: boolean;
onClose: () => void;
node: ValueChainNode;
isCompany: boolean;
colorScheme: string;
relatedCompanies: RelatedCompany[];
loadingRelated: boolean;
}
/**
* 获取节点类型对应的图标
*/
const getNodeTypeIcon = (type: string) => {
const icons: Record<string, React.ComponentType> = {
company: FaBuilding,
supplier: FaHandshake,
customer: FaUserTie,
product: FaIndustry,
service: FaCog,
channel: FaNetworkWired,
raw_material: FaFlask,
};
return icons[type] || FaBuilding;
};
/**
* 获取重要度对应的颜色
*/
const getImportanceColor = (score?: number): string => {
if (!score) return 'green';
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
/**
* 获取层级标签
*/
const getLevelLabel = (level?: number): { text: string; color: string } => {
if (level === undefined) return { text: '未知', color: 'gray' };
if (level < 0) return { text: '上游', color: 'orange' };
if (level === 0) return { text: '核心', color: 'blue' };
return { text: '下游', color: 'green' };
};
const RelatedCompaniesModal: React.FC<RelatedCompaniesModalProps> = ({
isOpen,
onClose,
node,
isCompany,
colorScheme,
relatedCompanies,
loadingRelated,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<HStack>
<Icon
as={getNodeTypeIcon(node.node_type)}
color={`${colorScheme}.500`}
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text>{node.node_name}</Text>
<HStack>
<Badge colorScheme={colorScheme}>{node.node_type}</Badge>
{isCompany && (
<Badge colorScheme="blue" variant="solid">
</Badge>
)}
</HStack>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
{node.node_description && (
<Box>
<Text fontWeight="bold" mb={2} color="gray.600">
</Text>
<Text fontSize="sm" lineHeight="1.6">
{node.node_description}
</Text>
</Box>
)}
<SimpleGrid columns={3} spacing={4}>
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">
{node.importance_score || 0}
</StatNumber>
<StatHelpText>
<Progress
value={node.importance_score}
size="sm"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
/>
</StatHelpText>
</Stat>
{node.market_share !== undefined && (
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">{node.market_share}%</StatNumber>
</Stat>
)}
{node.dependency_degree !== undefined && (
<Stat>
<StatLabel fontSize="xs"></StatLabel>
<StatNumber fontSize="lg">
{node.dependency_degree}%
</StatNumber>
<StatHelpText>
<Progress
value={node.dependency_degree}
size="sm"
colorScheme={
node.dependency_degree > 50 ? 'orange' : 'green'
}
borderRadius="full"
/>
</StatHelpText>
</Stat>
)}
</SimpleGrid>
<Divider />
<Box>
<HStack mb={3} justify="space-between">
<Text fontWeight="bold" color="gray.600">
</Text>
{loadingRelated && <Spinner size="sm" />}
</HStack>
{loadingRelated ? (
<Center py={4}>
<Spinner size="md" />
</Center>
) : relatedCompanies.length > 0 ? (
<VStack
align="stretch"
spacing={3}
maxH="400px"
overflowY="auto"
>
{relatedCompanies.map((company, idx) => {
const levelInfo = getLevelLabel(company.node_info?.node_level);
return (
<Card key={idx} variant="outline" size="sm">
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack flexWrap="wrap">
<Text fontSize="sm" fontWeight="bold">
{company.stock_name}
</Text>
<Badge size="sm" colorScheme="blue">
{company.stock_code}
</Badge>
<Badge
size="sm"
colorScheme={levelInfo.color}
variant="solid"
>
{levelInfo.text}
</Badge>
</HStack>
{company.company_name && (
<Text
fontSize="xs"
color="gray.500"
noOfLines={1}
>
{company.company_name}
</Text>
)}
</VStack>
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
colorScheme="blue"
onClick={() => {
window.location.href = `/company?stock_code=${company.stock_code}`;
}}
aria-label="查看公司详情"
/>
</HStack>
{company.node_info?.node_description && (
<Text
fontSize="xs"
color="gray.600"
noOfLines={2}
>
{company.node_info.node_description}
</Text>
)}
{company.relationships &&
company.relationships.length > 0 && (
<Box
pt={2}
borderTop="1px"
borderColor="gray.100"
>
<Text
fontSize="xs"
fontWeight="bold"
color="gray.600"
mb={1}
>
:
</Text>
<VStack align="stretch" spacing={1}>
{company.relationships.map((rel, ridx) => (
<HStack
key={ridx}
fontSize="xs"
spacing={2}
>
<Icon
as={
rel.role === 'source'
? FaArrowRight
: FaArrowLeft
}
color={
rel.role === 'source'
? 'green.500'
: 'orange.500'
}
boxSize={3}
/>
<Text color="gray.700" noOfLines={1}>
{rel.role === 'source'
? '流向'
: '来自'}
<Text
as="span"
fontWeight="medium"
mx={1}
>
{rel.connected_node}
</Text>
</Text>
</HStack>
))}
</VStack>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</VStack>
) : (
<Center py={4}>
<VStack spacing={2}>
<Icon as={FaBuilding} boxSize={8} color="gray.300" />
<Text fontSize="sm" color="gray.500">
</Text>
</VStack>
</Center>
)}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default RelatedCompaniesModal;

View File

@@ -0,0 +1,264 @@
/**
* 产业链节点卡片组件
*
* 显示产业链中的单个节点,点击可展开查看相关公司
* 黑金主题风格
*/
import React, { useState, memo } from 'react';
import {
Card,
CardBody,
VStack,
HStack,
Text,
Badge,
Icon,
Progress,
Box,
Tooltip,
useDisclosure,
useToast,
ScaleFade,
} from '@chakra-ui/react';
import {
FaBuilding,
FaHandshake,
FaUserTie,
FaIndustry,
FaCog,
FaNetworkWired,
FaFlask,
FaStar,
} from 'react-icons/fa';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
import RelatedCompaniesModal from './RelatedCompaniesModal';
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: 'white',
textSecondary: 'gray.400',
// 上游颜色
upstream: {
bg: 'rgba(237, 137, 54, 0.1)',
border: 'orange.600',
badge: 'orange',
icon: 'orange.400',
},
// 核心企业颜色
core: {
bg: 'rgba(66, 153, 225, 0.15)',
border: 'blue.500',
badge: 'blue',
icon: 'blue.400',
},
// 下游颜色
downstream: {
bg: 'rgba(72, 187, 120, 0.1)',
border: 'green.600',
badge: 'green',
icon: 'green.400',
},
};
/**
* 获取节点类型对应的图标
*/
const getNodeTypeIcon = (type: string) => {
const icons: Record<string, React.ComponentType> = {
company: FaBuilding,
supplier: FaHandshake,
customer: FaUserTie,
product: FaIndustry,
service: FaCog,
channel: FaNetworkWired,
raw_material: FaFlask,
regulator: FaBuilding,
end_user: FaUserTie,
};
return icons[type] || FaBuilding;
};
/**
* 获取重要度对应的颜色
*/
const getImportanceColor = (score?: number): string => {
if (!score) return 'green';
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
const ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = memo(({
node,
isCompany = false,
level = 0,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [relatedCompanies, setRelatedCompanies] = useState<RelatedCompany[]>([]);
const [loadingRelated, setLoadingRelated] = useState(false);
const toast = useToast();
// 根据层级确定颜色方案
const getColorConfig = () => {
if (isCompany || level === 0) return THEME.core;
if (level < 0) return THEME.upstream;
return THEME.downstream;
};
const colorConfig = getColorConfig();
// 获取相关公司数据
const fetchRelatedCompanies = async () => {
setLoadingRelated(true);
try {
const { data } = await axios.get(
`/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
node.node_name
)}`
);
if (data.success) {
setRelatedCompanies(data.data || []);
} else {
toast({
title: '获取相关公司失败',
description: data.message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
} catch (error) {
logger.error('ValueChainNodeCard', 'fetchRelatedCompanies', error, {
node_name: node.node_name,
});
toast({
title: '获取相关公司失败',
description: error instanceof Error ? error.message : '未知错误',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoadingRelated(false);
}
};
// 点击卡片打开模态框
const handleCardClick = () => {
onOpen();
if (relatedCompanies.length === 0) {
fetchRelatedCompanies();
}
};
return (
<>
<ScaleFade in={true} initialScale={0.9}>
<Card
bg={colorConfig.bg}
borderColor={colorConfig.border}
borderWidth={isCompany ? 2 : 1}
shadow={isCompany ? 'lg' : 'sm'}
cursor="pointer"
onClick={handleCardClick}
_hover={{
shadow: 'xl',
transform: 'translateY(-4px)',
borderColor: THEME.gold,
}}
transition="all 0.3s ease"
minH="140px"
>
<CardBody p={4}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between">
<HStack spacing={2}>
<Icon
as={getNodeTypeIcon(node.node_type)}
color={colorConfig.icon}
boxSize={5}
/>
{isCompany && (
<Badge colorScheme={colorConfig.badge} variant="solid">
</Badge>
)}
</HStack>
{node.importance_score !== undefined &&
node.importance_score >= 70 && (
<Tooltip label="重要节点">
<span>
<Icon as={FaStar} color={THEME.gold} boxSize={4} />
</span>
</Tooltip>
)}
</HStack>
<Text fontWeight="bold" fontSize="sm" color={THEME.textPrimary} noOfLines={2}>
{node.node_name}
</Text>
{node.node_description && (
<Text fontSize="xs" color={THEME.textSecondary} noOfLines={2}>
{node.node_description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
<Badge variant="subtle" size="sm" colorScheme={colorConfig.badge}>
{node.node_type}
</Badge>
{node.market_share !== undefined && (
<Badge variant="outline" size="sm" color={THEME.goldLight}>
{node.market_share}%
</Badge>
)}
</HStack>
{node.importance_score !== undefined && (
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
<Text fontSize="xs" fontWeight="bold" color={THEME.goldLight}>
{node.importance_score}
</Text>
</HStack>
<Progress
value={node.importance_score}
size="xs"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
bg="gray.600"
/>
</Box>
)}
</VStack>
</CardBody>
</Card>
</ScaleFade>
<RelatedCompaniesModal
isOpen={isOpen}
onClose={onClose}
node={node}
isCompany={isCompany}
colorScheme={colorConfig.badge}
relatedCompanies={relatedCompanies}
loadingRelated={loadingRelated}
/>
</>
);
});
ValueChainNodeCard.displayName = 'ValueChainNodeCard';
export default ValueChainNodeCard;

View File

@@ -0,0 +1,52 @@
/**
* 业务结构 Tab
*
* 包含:业务结构分析 + 业务板块详情
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import { BusinessStructureCard, BusinessSegmentsCard } from '../components';
import type { ComprehensiveData } from '../types';
export interface BusinessTabProps {
comprehensiveData?: ComprehensiveData;
cardBg?: string;
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
}
const BusinessTab: React.FC<BusinessTabProps> = memo(({
comprehensiveData,
cardBg,
expandedSegments,
onToggleSegment,
}) => {
return (
<TabPanelContainer showDisclaimer>
{/* 业务结构分析 */}
{comprehensiveData?.business_structure &&
comprehensiveData.business_structure.length > 0 && (
<BusinessStructureCard
businessStructure={comprehensiveData.business_structure}
cardBg={cardBg}
/>
)}
{/* 业务板块详情 */}
{comprehensiveData?.business_segments &&
comprehensiveData.business_segments.length > 0 && (
<BusinessSegmentsCard
businessSegments={comprehensiveData.business_segments}
expandedSegments={expandedSegments}
onToggleSegment={onToggleSegment}
cardBg={cardBg}
/>
)}
</TabPanelContainer>
);
});
BusinessTab.displayName = 'BusinessTab';
export default BusinessTab;

View File

@@ -0,0 +1,49 @@
/**
* 发展历程 Tab
*
* 包含:关键因素 + 发展时间线Grid 布局)
*/
import React, { memo } from 'react';
import { Grid, GridItem } from '@chakra-ui/react';
import TabPanelContainer from '@components/TabPanelContainer';
import { KeyFactorsCard, TimelineCard } from '../components';
import type { KeyFactorsData } from '../types';
export interface DevelopmentTabProps {
keyFactorsData?: KeyFactorsData;
cardBg?: string;
}
const DevelopmentTab: React.FC<DevelopmentTabProps> = memo(({
keyFactorsData,
cardBg,
}) => {
return (
<TabPanelContainer showDisclaimer>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={{ base: 2, lg: 1 }}>
{keyFactorsData?.key_factors && (
<KeyFactorsCard
keyFactors={keyFactorsData.key_factors}
cardBg={cardBg}
/>
)}
</GridItem>
<GridItem colSpan={{ base: 2, lg: 1 }}>
{keyFactorsData?.development_timeline && (
<TimelineCard
developmentTimeline={keyFactorsData.development_timeline}
cardBg={cardBg}
/>
)}
</GridItem>
</Grid>
</TabPanelContainer>
);
});
DevelopmentTab.displayName = 'DevelopmentTab';
export default DevelopmentTab;

View File

@@ -0,0 +1,58 @@
/**
* 战略分析 Tab
*
* 包含:核心定位 + 战略分析 + 竞争地位分析(含行业排名弹窗)
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import {
CorePositioningCard,
StrategyAnalysisCard,
CompetitiveAnalysisCard,
} from '../components';
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 (
<TabPanelContainer showDisclaimer>
{/* 核心定位卡片 */}
{comprehensiveData?.qualitative_analysis && (
<CorePositioningCard
qualitativeAnalysis={comprehensiveData.qualitative_analysis}
cardBg={cardBg}
/>
)}
{/* 战略分析 */}
{comprehensiveData?.qualitative_analysis?.strategy && (
<StrategyAnalysisCard
strategy={comprehensiveData.qualitative_analysis.strategy}
cardBg={cardBg}
/>
)}
{/* 竞争地位分析(包含行业排名弹窗) */}
{comprehensiveData?.competitive_position && (
<CompetitiveAnalysisCard
comprehensiveData={comprehensiveData}
industryRankData={industryRankData}
/>
)}
</TabPanelContainer>
);
});
StrategyTab.displayName = 'StrategyTab';
export default StrategyTab;

View File

@@ -0,0 +1,32 @@
/**
* 产业链 Tab
*
* 包含:产业链分析(层级视图 + Sankey 流向图)
*/
import React, { memo } from 'react';
import TabPanelContainer from '@components/TabPanelContainer';
import { ValueChainCard } from '../components';
import type { ValueChainData } from '../types';
export interface ValueChainTabProps {
valueChainData?: ValueChainData;
cardBg?: string;
}
const ValueChainTab: React.FC<ValueChainTabProps> = memo(({
valueChainData,
cardBg,
}) => {
return (
<TabPanelContainer showDisclaimer>
{valueChainData && (
<ValueChainCard valueChainData={valueChainData} cardBg={cardBg} />
)}
</TabPanelContainer>
);
});
ValueChainTab.displayName = 'ValueChainTab';
export default ValueChainTab;

View File

@@ -0,0 +1,14 @@
/**
* DeepAnalysisTab - Tab 组件导出
*/
export { default as StrategyTab } from './StrategyTab';
export { default as BusinessTab } from './BusinessTab';
export { default as ValueChainTab } from './ValueChainTab';
export { default as DevelopmentTab } from './DevelopmentTab';
// 导出类型
export type { StrategyTabProps } from './StrategyTab';
export type { BusinessTabProps } from './BusinessTab';
export type { ValueChainTabProps } from './ValueChainTab';
export type { DevelopmentTabProps } from './DevelopmentTab';

View File

@@ -0,0 +1,403 @@
/**
* DeepAnalysisTab 组件类型定义
*
* 深度分析 Tab 所需的所有数据接口类型
*/
// ==================== 格式化工具类型 ====================
export interface FormatUtils {
formatCurrency: (value: number | null | undefined) => string;
formatBusinessRevenue: (value: number | null | undefined, unit?: string) => string;
formatPercentage: (value: number | null | undefined) => string;
}
// ==================== 竞争力评分类型 ====================
export interface CompetitiveScores {
market_position?: number;
technology?: number;
brand?: number;
operation?: number;
finance?: number;
innovation?: number;
risk?: number;
growth?: number;
}
export interface CompetitiveRanking {
industry_rank: number;
total_companies: number;
}
export interface CompetitiveAnalysis {
main_competitors?: string;
competitive_advantages?: string;
competitive_disadvantages?: string;
}
export interface CompetitivePosition {
scores?: CompetitiveScores;
ranking?: CompetitiveRanking;
analysis?: CompetitiveAnalysis;
}
// ==================== 核心定位类型 ====================
/** 特性项(用于核心定位下方的两个区块:零售业务/综合金融) */
export interface FeatureItem {
/** 图标名称,如 'bank', 'fire' */
icon: string;
/** 标题,如 '零售业务' */
title: string;
/** 描述文字 */
description: string;
}
/** 投资亮点项(结构化) */
export interface InvestmentHighlightItem {
/** 图标名称,如 'users', 'trending-up' */
icon: string;
/** 标题,如 '综合金融优势' */
title: string;
/** 描述文字 */
description: string;
}
/** 商业模式板块 */
export interface BusinessModelSection {
/** 标题,如 '零售银行核心驱动' */
title: string;
/** 描述文字 */
description: string;
/** 可选的标签,如 ['AI应用深化', '大数据分析'] */
tags?: string[];
}
export interface CorePositioning {
/** 一句话介绍 */
one_line_intro?: string;
/** 核心特性2个显示在核心定位区域下方 */
features?: FeatureItem[];
/** 投资亮点 - 支持结构化数组(新格式)或字符串(旧格式) */
investment_highlights?: InvestmentHighlightItem[] | string;
/** 结构化商业模式数组 */
business_model_sections?: BusinessModelSection[];
/** 原 investment_highlights 文本格式(兼容旧数据,优先级低于 investment_highlights */
investment_highlights_text?: string;
/** 商业模式描述(兼容旧数据) */
business_model_desc?: string;
}
export interface Strategy {
strategy_description?: string;
strategic_initiatives?: string;
}
export interface QualitativeAnalysis {
core_positioning?: CorePositioning;
strategy?: Strategy;
}
// ==================== 业务结构类型 ====================
export interface FinancialMetrics {
revenue?: number;
revenue_ratio?: number;
gross_margin?: number;
}
export interface GrowthMetrics {
revenue_growth?: number;
}
export interface BusinessStructure {
business_name: string;
business_level: number;
revenue?: number;
revenue_unit?: string;
financial_metrics?: FinancialMetrics;
growth_metrics?: GrowthMetrics;
report_period?: string;
}
// ==================== 业务板块类型 ====================
export interface BusinessSegment {
segment_name: string;
segment_description?: string;
competitive_position?: string;
future_potential?: string;
key_products?: string;
market_share?: number;
revenue_contribution?: number;
}
// ==================== 综合数据类型 ====================
export interface ComprehensiveData {
qualitative_analysis?: QualitativeAnalysis;
competitive_position?: CompetitivePosition;
business_structure?: BusinessStructure[];
business_segments?: BusinessSegment[];
}
// ==================== 产业链类型 ====================
export interface ValueChainNode {
node_name: string;
node_type: string;
node_description?: string;
node_level?: number;
importance_score?: number;
market_share?: number;
dependency_degree?: number;
}
export interface ValueChainFlow {
source?: { node_name: string };
target?: { node_name: string };
flow_metrics?: {
flow_ratio?: string;
};
}
export interface NodesByLevel {
[key: string]: ValueChainNode[];
}
export interface ValueChainStructure {
nodes_by_level?: NodesByLevel;
}
export interface AnalysisSummary {
upstream_nodes?: number;
company_nodes?: number;
downstream_nodes?: number;
total_nodes?: number;
}
export interface ValueChainData {
value_chain_flows?: ValueChainFlow[];
value_chain_structure?: ValueChainStructure;
analysis_summary?: AnalysisSummary;
}
// ==================== 相关公司类型 ====================
export interface RelatedCompanyRelationship {
role: 'source' | 'target';
connected_node: string;
}
export interface RelatedCompanyNodeInfo {
node_level?: number;
node_description?: string;
}
export interface RelatedCompany {
stock_code: string;
stock_name: string;
company_name?: string;
node_info?: RelatedCompanyNodeInfo;
relationships?: RelatedCompanyRelationship[];
}
// ==================== 关键因素类型 ====================
export type ImpactDirection = 'positive' | 'negative' | 'neutral' | 'mixed';
export interface KeyFactor {
factor_name: string;
factor_value: string | number;
factor_unit?: string;
factor_desc?: string;
impact_direction?: ImpactDirection;
impact_weight?: number;
year_on_year?: number;
report_period?: string;
}
export interface FactorCategory {
category_name: string;
factors: KeyFactor[];
}
export interface KeyFactors {
total_factors?: number;
categories: FactorCategory[];
}
// ==================== 时间线事件类型 ====================
export interface ImpactMetrics {
is_positive?: boolean;
impact_score?: number;
}
export interface RelatedInfo {
financial_impact?: string;
}
export interface TimelineEvent {
event_title: string;
event_date: string;
event_type: string;
event_desc: string;
impact_metrics?: ImpactMetrics;
related_info?: RelatedInfo;
}
export interface TimelineStatistics {
positive_events?: number;
negative_events?: number;
}
export interface DevelopmentTimeline {
events: TimelineEvent[];
statistics?: TimelineStatistics;
}
// ==================== 关键因素数据类型 ====================
export interface KeyFactorsData {
key_factors?: KeyFactors;
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 类型 */
export type DeepAnalysisTabKey = 'strategy' | 'business' | 'valueChain' | 'development';
export interface DeepAnalysisTabProps {
comprehensiveData?: ComprehensiveData;
valueChainData?: ValueChainData;
keyFactorsData?: KeyFactorsData;
industryRankData?: IndustryRankData[];
loading?: boolean;
cardBg?: string;
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
/** 当前激活的 Tab受控模式 */
activeTab?: DeepAnalysisTabKey;
/** Tab 切换回调(懒加载触发) */
onTabChange?: (index: number, tabKey: string) => void;
}
// ==================== 子组件 Props 类型 ====================
export interface DisclaimerBoxProps {
// 无需 props
}
export interface ScoreBarProps {
label: string;
score?: number;
icon?: React.ComponentType;
}
export interface BusinessTreeItemProps {
business: BusinessStructure;
depth?: number;
}
export interface KeyFactorCardProps {
factor: KeyFactor;
}
export interface ValueChainNodeCardProps {
node: ValueChainNode;
isCompany?: boolean;
level?: number;
}
export interface TimelineComponentProps {
events: TimelineEvent[];
}
// ==================== 图表配置类型 ====================
export interface RadarIndicator {
name: string;
max: number;
}
export interface RadarChartOption {
tooltip: { trigger: string };
radar: {
indicator: RadarIndicator[];
shape: string;
splitNumber: number;
name: { textStyle: { color: string; fontSize: number } };
splitLine: { lineStyle: { color: string[] } };
splitArea: { show: boolean; areaStyle: { color: string[] } };
axisLine: { lineStyle: { color: string } };
};
series: Array<{
name: string;
type: string;
data: Array<{
value: number[];
name: string;
symbol: string;
symbolSize: number;
lineStyle: { width: number; color: string };
areaStyle: { color: string };
label: { show: boolean; formatter: (params: { value: number }) => number; color: string; fontSize: number };
}>;
}>;
}
export interface SankeyNode {
name: string;
}
export interface SankeyLink {
source: string;
target: string;
value: number;
lineStyle: { color: string; opacity: number };
}
export interface SankeyChartOption {
tooltip: { trigger: string; triggerOn: string };
series: Array<{
type: string;
layout: string;
emphasis: { focus: string };
data: SankeyNode[];
links: SankeyLink[];
lineStyle: { color: string; curveness: number };
label: { color: string; fontSize: number };
}>;
}

View File

@@ -0,0 +1,139 @@
/**
* DeepAnalysisTab 图表配置工具
*
* 生成雷达图和桑基图的 ECharts 配置
*/
import type {
ComprehensiveData,
ValueChainData,
RadarChartOption,
SankeyChartOption,
} from '../types';
/**
* 生成竞争力雷达图配置
* @param comprehensiveData - 综合分析数据
* @returns ECharts 雷达图配置,或 null数据不足时
*/
export const getRadarChartOption = (
comprehensiveData?: ComprehensiveData
): RadarChartOption | null => {
if (!comprehensiveData?.competitive_position?.scores) return null;
const scores = comprehensiveData.competitive_position.scores;
const indicators = [
{ name: '市场地位', max: 100 },
{ name: '技术实力', max: 100 },
{ name: '品牌价值', max: 100 },
{ name: '运营效率', max: 100 },
{ name: '财务健康', max: 100 },
{ name: '创新能力', max: 100 },
{ name: '风险控制', max: 100 },
{ name: '成长潜力', max: 100 },
];
const data = [
scores.market_position || 0,
scores.technology || 0,
scores.brand || 0,
scores.operation || 0,
scores.finance || 0,
scores.innovation || 0,
scores.risk || 0,
scores.growth || 0,
];
return {
tooltip: { trigger: 'item' },
radar: {
indicator: indicators,
shape: 'polygon',
splitNumber: 4,
name: { textStyle: { color: '#666', fontSize: 12 } },
splitLine: {
lineStyle: { color: ['#e8e8e8', '#e0e0e0', '#d0d0d0', '#c0c0c0'] },
},
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
},
axisLine: { lineStyle: { color: '#ddd' } },
},
series: [
{
name: '竞争力评分',
type: 'radar',
data: [
{
value: data,
name: '当前评分',
symbol: 'circle',
symbolSize: 5,
lineStyle: { width: 2, color: '#3182ce' },
areaStyle: { color: 'rgba(49, 130, 206, 0.3)' },
label: {
show: true,
formatter: (params: { value: number }) => params.value,
color: '#3182ce',
fontSize: 10,
},
},
],
},
],
};
};
/**
* 生成产业链桑基图配置
* @param valueChainData - 产业链数据
* @returns ECharts 桑基图配置,或 null数据不足时
*/
export const getSankeyChartOption = (
valueChainData?: ValueChainData
): SankeyChartOption | null => {
if (
!valueChainData?.value_chain_flows ||
valueChainData.value_chain_flows.length === 0
) {
return null;
}
const nodes = new Set<string>();
const links: Array<{
source: string;
target: string;
value: number;
lineStyle: { color: string; opacity: number };
}> = [];
valueChainData.value_chain_flows.forEach((flow) => {
if (!flow?.source?.node_name || !flow?.target?.node_name) return;
nodes.add(flow.source.node_name);
nodes.add(flow.target.node_name);
links.push({
source: flow.source.node_name,
target: flow.target.node_name,
value: parseFloat(flow.flow_metrics?.flow_ratio || '1') || 1,
lineStyle: { color: 'source', opacity: 0.6 },
});
});
return {
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
series: [
{
type: 'sankey',
layout: 'none',
emphasis: { focus: 'adjacency' },
data: Array.from(nodes).map((name) => ({ name })),
links: links,
lineStyle: { color: 'gradient', curveness: 0.5 },
label: { color: '#333', fontSize: 10 },
},
],
};
};

View File

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

View File

@@ -0,0 +1,96 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ActualControlCard.tsx
// 实际控制人卡片组件
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
Stat,
StatLabel,
StatNumber,
StatHelpText,
} from "@chakra-ui/react";
import { FaCrown } from "react-icons/fa";
import type { ActualControl } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
interface ActualControlCardProps {
actualControl: ActualControl[];
}
/**
* 实际控制人卡片
*/
const ActualControlCard: React.FC<ActualControlCardProps> = ({ actualControl = [] }) => {
if (!actualControl.length) return null;
const data = actualControl[0];
return (
<Box>
<HStack mb={4}>
<Icon as={FaCrown} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}></Heading>
</HStack>
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardBody>
<HStack
justify="space-between"
flexDir={{ base: "column", md: "row" }}
align={{ base: "stretch", md: "center" }}
gap={4}
>
<VStack align={{ base: "center", md: "start" }}>
<Text fontWeight="bold" fontSize="lg" color={THEME.textPrimary}>
{data.actual_controller_name}
</Text>
<HStack>
<Badge colorScheme="purple">{data.control_type}</Badge>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(data.end_date)}
</Text>
</HStack>
</VStack>
<Stat textAlign={{ base: "center", md: "right" }}>
<StatLabel color={THEME.textSecondary}></StatLabel>
<StatNumber color={THEME.goldLight}>
{formatPercentage(data.holding_ratio)}
</StatNumber>
<StatHelpText color={THEME.textSecondary}>{formatShares(data.holding_shares)}</StatHelpText>
</Stat>
</HStack>
</CardBody>
</Card>
</Box>
);
};
export default ActualControlCard;

View File

@@ -0,0 +1,234 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
// 股权集中度卡片组件
import React, { useMemo, useRef, useEffect } from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
CardHeader,
SimpleGrid,
} from "@chakra-ui/react";
import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa";
import * as echarts from "echarts";
import type { Concentration } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
interface ConcentrationCardProps {
concentration: Concentration[];
}
// 饼图颜色配置(黑金主题)
const PIE_COLORS = [
"#D4AF37", // 金色 - 前1大股东
"#F0D78C", // 浅金色 - 第2-3大股东
"#B8860B", // 暗金色 - 第4-5大股东
"#DAA520", // 金麒麟色 - 第6-10大股东
"#4A5568", // 灰色 - 其他股东
];
/**
* 股权集中度卡片
*/
const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [] }) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
// 按日期分组
const groupedData = useMemo(() => {
const grouped: Record<string, Record<string, Concentration>> = {};
concentration.forEach((item) => {
if (!grouped[item.end_date]) {
grouped[item.end_date] = {};
}
grouped[item.end_date][item.stat_item] = item;
});
return Object.entries(grouped)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 1); // 只取最新一期
}, [concentration]);
// 计算饼图数据
const pieData = useMemo(() => {
if (groupedData.length === 0) return [];
const [, items] = groupedData[0];
const top1 = items["前1大股东"]?.holding_ratio || 0;
const top3 = items["前3大股东"]?.holding_ratio || 0;
const top5 = items["前5大股东"]?.holding_ratio || 0;
const top10 = items["前10大股东"]?.holding_ratio || 0;
return [
{ name: "前1大股东", value: Number((top1 * 100).toFixed(2)) },
{ name: "第2-3大股东", value: Number(((top3 - top1) * 100).toFixed(2)) },
{ name: "第4-5大股东", value: Number(((top5 - top3) * 100).toFixed(2)) },
{ name: "第6-10大股东", value: Number(((top10 - top5) * 100).toFixed(2)) },
{ name: "其他股东", value: Number(((1 - top10) * 100).toFixed(2)) },
].filter(item => item.value > 0);
}, [groupedData]);
// 初始化和更新图表
useEffect(() => {
if (!chartRef.current || pieData.length === 0) return;
// 使用 requestAnimationFrame 确保 DOM 渲染完成后再初始化
const initChart = () => {
if (!chartRef.current) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const option: echarts.EChartsOption = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
formatter: "{b}: {c}%",
backgroundColor: "rgba(0,0,0,0.8)",
borderColor: THEME.gold,
textStyle: { color: "#fff" },
},
legend: {
orient: "vertical",
right: 10,
top: "center",
textStyle: { color: THEME.textSecondary, fontSize: 11 },
itemWidth: 12,
itemHeight: 12,
},
series: [
{
name: "股权集中度",
type: "pie",
radius: ["40%", "70%"],
center: ["35%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
borderColor: THEME.cardBg,
borderWidth: 2,
},
label: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: "bold",
color: THEME.textPrimary,
formatter: "{b}\n{c}%",
},
},
labelLine: { show: false },
data: pieData.map((item, index) => ({
...item,
itemStyle: { color: PIE_COLORS[index] },
})),
},
],
};
chartInstance.current.setOption(option);
// 延迟 resize 确保容器尺寸已计算完成
setTimeout(() => {
chartInstance.current?.resize();
}, 100);
};
// 延迟初始化,确保布局完成
const rafId = requestAnimationFrame(initChart);
// 响应式
const handleResize = () => chartInstance.current?.resize();
window.addEventListener("resize", handleResize);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", handleResize);
};
}, [pieData]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
};
}, []);
if (!concentration.length) return null;
return (
<Box>
<HStack mb={4}>
<Icon as={FaChartPie} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}></Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{/* 数据卡片 */}
{groupedData.map(([date, items]) => (
<Card key={date} bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardHeader pb={2}>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(date)}
</Text>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
{Object.entries(items).map(([key, item]) => (
<HStack key={key} justify="space-between">
<Text fontSize="sm" color={THEME.textPrimary}>{item.stat_item}</Text>
<HStack>
<Text fontWeight="bold" color={THEME.goldLight}>
{formatPercentage(item.holding_ratio)}
</Text>
{item.ratio_change && (
<Badge
colorScheme={item.ratio_change > 0 ? "red" : "green"}
>
<Icon
as={item.ratio_change > 0 ? FaArrowUp : FaArrowDown}
mr={1}
boxSize={3}
/>
{Math.abs(item.ratio_change).toFixed(2)}%
</Badge>
)}
</HStack>
</HStack>
))}
</VStack>
</CardBody>
</Card>
))}
{/* 饼图 */}
<Card bg={THEME.cardBg} borderColor={THEME.border} borderWidth="1px">
<CardBody p={2}>
<Box ref={chartRef} h="180px" w="100%" />
</CardBody>
</Card>
</SimpleGrid>
</Box>
);
};
export default ConcentrationCard;

View File

@@ -0,0 +1,226 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx
// 股东表格组件(合并版)- 支持十大股东和十大流通股东
import React, { useMemo } from "react";
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
import { Table, Tag, Tooltip, ConfigProvider } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FaUsers, FaChartLine } from "react-icons/fa";
import type { Shareholder } from "../../types";
import { THEME } from "../../BasicInfoTab/config";
// antd 表格黑金主题配置
const TABLE_THEME = {
token: {
colorBgContainer: "#2D3748", // gray.700
colorText: "white",
colorTextHeading: "#D4AF37", // 金色
colorBorderSecondary: "rgba(212, 175, 55, 0.3)",
},
components: {
Table: {
headerBg: "#1A202C", // gray.900
headerColor: "#D4AF37", // 金色
rowHoverBg: "rgba(212, 175, 55, 0.15)", // 金色半透明,文字更清晰
borderColor: "rgba(212, 175, 55, 0.2)",
},
},
};
// 格式化工具函数
const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
// 股东类型颜色映射
const shareholderTypeColors: Record<string, string> = {
: "blue",
: "green",
: "purple",
QFII: "orange",
: "red",
: "cyan",
: "geekblue",
: "magenta",
: "purple",
: "blue",
};
const getShareholderTypeColor = (type: string | undefined): string => {
if (!type) return "default";
for (const [key, color] of Object.entries(shareholderTypeColors)) {
if (type.includes(key)) return color;
}
return "default";
};
interface ShareholdersTableProps {
type?: "top" | "circulation";
shareholders: Shareholder[];
title?: string;
}
/**
* 股东表格组件
* @param type - 表格类型: "top" 十大股东 | "circulation" 十大流通股东
* @param shareholders - 股东数据数组
* @param title - 自定义标题
*/
const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
type = "top",
shareholders = [],
title,
}) => {
const isMobile = useBreakpointValue({ base: true, md: false });
// 配置
const config = useMemo(() => {
if (type === "circulation") {
return {
title: title || "十大流通股东",
icon: FaChartLine,
iconColor: "purple.500",
ratioField: "circulation_share_ratio" as keyof Shareholder,
ratioLabel: "流通股比例",
rankColor: "orange",
showNature: true, // 与十大股东保持一致
};
}
return {
title: title || "十大股东",
icon: FaUsers,
iconColor: "green.500",
ratioField: "total_share_ratio" as keyof Shareholder,
ratioLabel: "持股比例",
rankColor: "red",
showNature: true,
};
}, [type, title]);
// 表格列定义
const columns: ColumnsType<Shareholder> = useMemo(() => {
const baseColumns: ColumnsType<Shareholder> = [
{
title: "排名",
dataIndex: "shareholder_rank",
key: "rank",
width: 45,
render: (rank: number, _: Shareholder, index: number) => (
<Tag color={index < 3 ? config.rankColor : "default"}>
{rank || index + 1}
</Tag>
),
},
{
title: "股东名称",
dataIndex: "shareholder_name",
key: "name",
ellipsis: true,
render: (name: string) => (
<Tooltip title={name}>
<span style={{ fontWeight: 500, color: "#D4AF37" }}>{name}</span>
</Tooltip>
),
},
{
title: "股东类型",
dataIndex: "shareholder_type",
key: "type",
width: 90,
responsive: ["md"],
render: (shareholderType: string) => (
<Tag color={getShareholderTypeColor(shareholderType)}>{shareholderType || "-"}</Tag>
),
},
{
title: "持股数量",
dataIndex: "holding_shares",
key: "shares",
width: 100,
align: "right",
responsive: ["md"],
sorter: (a: Shareholder, b: Shareholder) => (a.holding_shares || 0) - (b.holding_shares || 0),
render: (shares: number) => (
<span style={{ color: "#D4AF37" }}>{formatShares(shares)}</span>
),
},
{
title: <span style={{ whiteSpace: "nowrap" }}>{config.ratioLabel}</span>,
dataIndex: config.ratioField as string,
key: "ratio",
width: 110,
align: "right",
sorter: (a: Shareholder, b: Shareholder) => {
const aVal = (a[config.ratioField] as number) || 0;
const bVal = (b[config.ratioField] as number) || 0;
return aVal - bVal;
},
defaultSortOrder: "descend",
render: (ratio: number) => (
<span style={{ color: type === "circulation" ? "#805AD5" : "#3182CE", fontWeight: "bold" }}>
{formatPercentage(ratio)}
</span>
),
},
];
// 十大股东显示股份性质
if (config.showNature) {
baseColumns.push({
title: "股份性质",
dataIndex: "share_nature",
key: "nature",
width: 80,
responsive: ["lg"],
render: (nature: string) => (
<Tag color="default">{nature || "流通股"}</Tag>
),
});
}
return baseColumns;
}, [config, type]);
if (!shareholders.length) return null;
// 获取数据日期
const reportDate = shareholders[0]?.end_date;
return (
<Box>
<HStack mb={4}>
<Icon as={config.icon} color={THEME.gold} boxSize={5} />
<Heading size="sm" color={THEME.gold}>{config.title}</Heading>
{reportDate && <Badge colorScheme="yellow" variant="subtle">{formatDate(reportDate)}</Badge>}
</HStack>
<ConfigProvider theme={TABLE_THEME}>
<Table
columns={columns}
dataSource={shareholders.slice(0, 10)}
rowKey={(record: Shareholder, index?: number) => `${record.shareholder_name}-${index}`}
pagination={false}
size={isMobile ? "small" : "middle"}
scroll={{ x: isMobile ? 400 : undefined }}
/>
</ConfigProvider>
</Box>
);
};
export default ShareholdersTable;

View File

@@ -0,0 +1,6 @@
// src/views/Company/components/CompanyOverview/components/shareholder/index.ts
// 股权结构子组件汇总导出
export { default as ActualControlCard } from "./ActualControlCard";
export { default as ConcentrationCard } from "./ConcentrationCard";
export { default as ShareholdersTable } from "./ShareholdersTable";

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Announcement } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseAnnouncementsDataResult {
announcements: Announcement[];
loading: boolean;
error: string | null;
}
/**
* 公告数据 Hook
* @param stockCode - 股票代码
*/
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { announcements, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { BasicInfo } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBasicInfoResult {
basicInfo: BasicInfo | null;
loading: boolean;
error: string | null;
}
/**
* 公司基本信息 Hook
* @param stockCode - 股票代码
*/
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { basicInfo, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Branch } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseBranchesDataResult {
branches: Branch[];
loading: boolean;
error: string | null;
}
/**
* 分支机构数据 Hook
* @param stockCode - 股票代码
*/
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { branches, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseDisclosureDataResult {
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
error: string | null;
}
/**
* 披露日程数据 Hook
* @param stockCode - 股票代码
*/
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => {
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { disclosureSchedule, loading, error };
};

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { Management } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseManagementDataResult {
management: Management[];
loading: boolean;
error: string | null;
}
/**
* 管理团队数据 Hook
* @param stockCode - 股票代码
*/
export const useManagementData = (stockCode?: string): UseManagementDataResult => {
const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return { management, loading, error };
};

View File

@@ -0,0 +1,82 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
import type { ActualControl, Concentration, Shareholder } from "../types";
interface ApiResponse<T> {
success: boolean;
data: T;
}
interface UseShareholderDataResult {
actualControl: ActualControl[];
concentration: Concentration[];
topShareholders: Shareholder[];
topCirculationShareholders: Shareholder[];
loading: boolean;
error: string | null;
}
/**
* 股权结构数据 Hook
* @param stockCode - 股票代码
*/
export const useShareholderData = (stockCode?: string): UseShareholderDataResult => {
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!stockCode) return;
const controller = new AbortController();
const loadData = async () => {
setLoading(true);
setError(null);
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);
}
};
loadData();
return () => controller.abort();
}, [stockCode]);
return {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
error,
};
};

View File

@@ -0,0 +1,31 @@
// src/views/Company/components/CompanyOverview/index.tsx
// 公司档案 - 主组件(组合层)
import React from "react";
import { VStack } from "@chakra-ui/react";
import type { CompanyOverviewProps } from "./types";
// 子组件
import BasicInfoTab from "./BasicInfoTab";
/**
* 公司档案组件
*
* 功能:
* - 显示基本信息 Tab内部懒加载各子 Tab 数据)
*
* 懒加载策略:
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo
*/
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
return (
<VStack spacing={6} align="stretch">
{/* 基本信息内容 - 传入 stockCode内部懒加载各 Tab 数据 */}
<BasicInfoTab stockCode={stockCode} />
</VStack>
);
};
export default CompanyOverview;

View File

@@ -0,0 +1,125 @@
// src/views/Company/components/CompanyOverview/types.ts
// 公司概览组件类型定义
/**
* 公司基本信息
*/
export interface BasicInfo {
ORGNAME?: string;
SECNAME?: string;
SECCODE?: string;
sw_industry_l1?: string;
sw_industry_l2?: string;
sw_industry_l3?: string;
legal_representative?: string;
chairman?: string;
general_manager?: string;
establish_date?: string;
reg_capital?: number;
province?: string;
city?: string;
website?: string;
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;
}
/**
* 实际控制人
*/
export interface ActualControl {
actual_controller_name?: string;
controller_name?: string;
control_type?: string;
controller_type?: string;
holding_ratio?: number;
holding_shares?: number;
end_date?: string;
}
/**
* 股权集中度
*/
export interface Concentration {
top1_ratio?: number;
top5_ratio?: number;
top10_ratio?: number;
stat_item?: string;
holding_ratio?: number;
ratio_change?: number;
end_date?: string;
}
/**
* 管理层信息
*/
export interface Management {
name?: string;
position?: string;
position_name?: string;
position_category?: string;
start_date?: string;
end_date?: string;
gender?: string;
education?: string;
birth_year?: string;
nationality?: string;
}
/**
* 股东信息
*/
export interface Shareholder {
shareholder_name?: string;
shareholder_type?: string;
shareholder_rank?: number;
holding_ratio?: number;
holding_amount?: number;
holding_shares?: number;
total_share_ratio?: number;
circulation_share_ratio?: number;
share_nature?: string;
end_date?: string;
}
/**
* 分支机构
*/
export interface Branch {
branch_name?: string;
address?: string;
}
/**
* 公告信息
*/
export interface Announcement {
title?: string;
publish_date?: string;
url?: string;
}
/**
* 披露计划
*/
export interface DisclosureSchedule {
report_type?: string;
disclosure_date?: string;
}
/**
* CompanyOverview 组件 Props
*/
export interface CompanyOverviewProps {
stockCode?: string;
}

View File

@@ -0,0 +1,26 @@
// src/views/Company/components/CompanyOverview/utils.ts
// 公司概览格式化工具函数
/**
* 格式化注册资本
* @param value - 注册资本(万元)
* @returns 格式化后的字符串
*/
export const formatRegisteredCapital = (value: number | null | undefined): string => {
if (!value && value !== 0) return "-";
const absValue = Math.abs(value);
if (absValue >= 100000) {
return (value / 10000).toFixed(2) + "亿元";
}
return value.toFixed(2) + "万元";
};
/**
* 格式化日期
* @param dateString - 日期字符串
* @returns 格式化后的日期字符串
*/
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("zh-CN");
};

View File

@@ -0,0 +1,75 @@
// src/views/Company/components/CompanyTabs/index.js
// Tab 容器组件 - 使用通用 TabContainer 组件
import React from 'react';
import TabContainer from '@components/TabContainer';
import { COMPANY_TABS, getTabNameByIndex } from '../../constants';
// 子组件导入Tab 内容组件)
import CompanyOverview from '../CompanyOverview';
import DeepAnalysis from '../DeepAnalysis';
import MarketDataView from '../MarketDataView';
import FinancialPanorama from '../FinancialPanorama';
import ForecastReport from '../ForecastReport';
import DynamicTracking from '../DynamicTracking';
/**
* Tab 组件映射
*/
const TAB_COMPONENTS = {
overview: CompanyOverview,
analysis: DeepAnalysis,
market: MarketDataView,
financial: FinancialPanorama,
forecast: ForecastReport,
tracking: DynamicTracking,
};
/**
* 构建 TabContainer 所需的 tabs 配置
* 合并 COMPANY_TABS 和对应的组件
*/
const buildTabsConfig = () => {
return COMPANY_TABS.map((tab) => ({
...tab,
component: TAB_COMPONENTS[tab.key],
}));
};
// 预构建 tabs 配置(避免每次渲染重新计算)
const TABS_CONFIG = buildTabsConfig();
/**
* 公司详情 Tab 容器组件
*
* 功能:
* - 使用通用 TabContainer 组件
* - 保持黑金主题风格
* - 触发 Tab 变更追踪
*
* @param {Object} props
* @param {string} props.stockCode - 当前股票代码
* @param {Function} props.onTabChange - Tab 变更回调 (index, tabName, prevIndex) => void
*/
const CompanyTabs = ({ stockCode, onTabChange }) => {
/**
* 处理 Tab 切换
* 转换 tabKey 为 tabName 以保持原有回调格式
*/
const handleTabChange = (index, tabKey, prevIndex) => {
const tabName = getTabNameByIndex(index);
onTabChange?.(index, tabName, prevIndex);
};
return (
<TabContainer
tabs={TABS_CONFIG}
componentProps={{ stockCode }}
onTabChange={handleTabChange}
themePreset="blackGold"
borderRadius="16px"
/>
);
};
export default CompanyTabs;

View File

@@ -0,0 +1,229 @@
// src/views/Company/components/DeepAnalysis/index.js
// 深度分析 - 独立一级 Tab 组件(懒加载版本)
import React, { useState, useEffect, useCallback, useRef } from "react";
import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
/**
* Tab 与 API 接口映射
* - strategy 和 business 共用 comprehensive 接口
*/
const TAB_API_MAP = {
strategy: "comprehensive",
business: "comprehensive",
valueChain: "valueChain",
development: "keyFactors",
};
/**
* 深度分析组件
*
* 功能:
* - 按 Tab 懒加载数据(默认只加载战略分析)
* - 已加载的数据缓存,切换 Tab 不重复请求
* - 管理展开状态
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DeepAnalysis = ({ stockCode }) => {
// 当前 Tab
const [activeTab, setActiveTab] = useState("strategy");
// 数据状态
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,
});
// 业务板块展开状态
const [expandedSegments, setExpandedSegments] = useState({});
// 用于追踪当前 stockCode避免竞态条件
const currentStockCodeRef = useRef(stockCode);
// 切换业务板块展开状态
const toggleSegmentExpansion = (segmentIndex) => {
setExpandedSegments((prev) => ({
...prev,
[segmentIndex]: !prev[segmentIndex],
}));
};
/**
* 加载指定接口的数据
*/
const loadApiData = useCallback(
async (apiKey) => {
if (!stockCode) return;
// 已加载则跳过
if (loadedApisRef.current[apiKey]) return;
try {
switch (apiKey) {
case "comprehensive":
setComprehensiveLoading(true);
const { data: comprehensiveRes } = await axios.get(
`/api/company/comprehensive-analysis/${stockCode}`
);
// 检查 stockCode 是否已变更(防止竞态)
if (currentStockCodeRef.current === stockCode) {
if (comprehensiveRes.success)
setComprehensiveData(comprehensiveRes.data);
loadedApisRef.current.comprehensive = true;
}
break;
case "valueChain":
setValueChainLoading(true);
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;
}
break;
case "keyFactors":
setKeyFactorsLoading(true);
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;
}
} catch (err) {
logger.error("DeepAnalysis", `loadApiData:${apiKey}`, err, {
stockCode,
});
} finally {
// 清除 loading 状态
if (apiKey === "comprehensive") setComprehensiveLoading(false);
if (apiKey === "valueChain") setValueChainLoading(false);
if (apiKey === "keyFactors") setKeyFactorsLoading(false);
if (apiKey === "industryRank") setIndustryRankLoading(false);
}
},
[stockCode]
);
/**
* 根据 Tab 加载对应的数据
*/
const loadTabData = useCallback(
(tabKey) => {
const apiKey = TAB_API_MAP[tabKey];
if (apiKey) {
loadApiData(apiKey);
}
},
[loadApiData]
);
/**
* Tab 切换回调
*/
const handleTabChange = useCallback(
(index, tabKey) => {
setActiveTab(tabKey);
loadTabData(tabKey);
},
[loadTabData]
);
// stockCode 变更时重置并加载默认 Tab 数据
useEffect(() => {
if (stockCode) {
// 更新 ref
currentStockCodeRef.current = stockCode;
// 重置所有数据和状态
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setIndustryRankData(null);
setExpandedSegments({});
loadedApisRef.current = {
comprehensive: false,
valueChain: false,
keyFactors: false,
industryRank: false,
};
// 重置为默认 Tab 并加载数据
setActiveTab("strategy");
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank
loadApiData("comprehensive");
loadApiData("industryRank");
}
}, [stockCode, loadApiData]);
// 计算当前 Tab 的 loading 状态
const getCurrentLoading = () => {
const apiKey = TAB_API_MAP[activeTab];
switch (apiKey) {
case "comprehensive":
return comprehensiveLoading;
case "valueChain":
return valueChainLoading;
case "keyFactors":
return keyFactorsLoading;
default:
return false;
}
};
return (
<DeepAnalysisTab
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
industryRankData={industryRankData}
loading={getCurrentLoading()}
cardBg="white"
expandedSegments={expandedSegments}
onToggleSegment={toggleSegmentExpansion}
activeTab={activeTab}
onTabChange={handleTabChange}
/>
);
};
export default DeepAnalysis;

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

@@ -0,0 +1,67 @@
// src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@chakra-ui/react';
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
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, component: NewsPanel },
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
];
/**
* 动态跟踪组件
*
* 功能:
* - 使用 SubTabContainer 实现二级导航
* - Tab1: 新闻动态
* - Tab2: 公司公告
* - Tab3: 财报披露日程
* - Tab4: 业绩预告
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DynamicTracking = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || '000001');
const [activeTab, setActiveTab] = useState(0);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode, stockCode]);
// 传递给子组件的 props
const componentProps = useMemo(
() => ({
stockCode,
}),
[stockCode]
);
return (
<Box>
<SubTabContainer
tabs={TRACKING_TABS}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onTabChange={(index) => setActiveTab(index)}
isLazy
/>
</Box>
);
};
export default DynamicTracking;

View File

@@ -0,0 +1,326 @@
/**
* 资产负债表组件 - Ant Design 黑金主题
*/
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,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
} from '../constants';
import { getValueByPath } from '../utils';
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,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 所有分类配置
const allSections = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
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 (
<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>
);
};
export default BalanceSheetTable;

View File

@@ -0,0 +1,269 @@
/**
* 现金流量表组件 - Ant Design 黑金主题
*/
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,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 8);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
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>
)}
</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]);
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' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default CashflowTable;

View File

@@ -0,0 +1,50 @@
/**
* 综合对比分析组件 - 黑金主题
*/
import React from '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;
const revenueData = comparison
.map((item) => ({
period: formatUtils.getReportType(item.period),
value: item.performance.revenue / 100000000, // 转换为亿
}))
.reverse();
const profitData = comparison
.map((item) => ({
period: formatUtils.getReportType(item.period),
value: item.performance.net_profit / 100000000, // 转换为亿
}))
.reverse();
const chartOption = getComparisonChartOption(revenueData, profitData);
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={chartOption} style={{ height: '350px' }} />
</Box>
);
};
export default ComparisonAnalysis;

View File

@@ -0,0 +1,360 @@
/**
* 财务指标表格组件 - Ant Design 黑金主题
*/
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';
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,
}) => {
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
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 (
<Box>
{/* 分类选择器 */}
<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'}
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.replace('指标', '')}
</Button>
)
)}
</HStack>
{/* 指标表格 */}
<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' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
{/* 关键指标快速对比 */}
{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'),
format: 'percent',
},
{
label: '毛利率',
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
format: 'percent',
},
{
label: '净利率',
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
format: 'percent',
},
{
label: '流动比率',
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
format: 'decimal',
},
{
label: '资产负债率',
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
format: 'percent',
},
{
label: '研发费用率',
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
format: 'percent',
},
].map((item, idx) => (
<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" color="#D4AF37">
{item.format === 'percent'
? formatUtils.formatPercent(item.value)
: item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
)}
</Box>
);
};
export default FinancialMetricsTable;

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

@@ -0,0 +1,326 @@
/**
* 利润表组件 - Ant Design 黑金主题
*/
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, 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,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
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 (
<Tooltip
title={
<Box>
<Text>: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<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
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{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 (
<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>
);
};
export default IncomeStatementTable;

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