Compare commits

...

57 Commits

Author SHA1 Message Date
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
99 changed files with 13481 additions and 5546 deletions

2
app.py
View File

@@ -165,7 +165,7 @@ WECHAT_OPEN_APPID = 'wxa8d74c47041b5f87'
WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
# 微信公众号配置H5 网页授权用)
WECHAT_MP_APPID = 'wx4e4b759f8fa9e43a'
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
WECHAT_MP_APPSECRET = 'ef1ca9064af271bb0405330efbc495aa'
# 微信回调地址

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

@@ -544,19 +544,13 @@ const InvestmentCalendar = () => {
render: (concepts) => (
<Space wrap>
{concepts && concepts.length > 0 ? (
concepts.slice(0, 3).map((concept, index) => {
// 兼容多种数据格式:字符串、数组、对象
const conceptName = typeof concept === 'string'
? concept
: Array.isArray(concept)
? concept[0]
: concept?.concept || concept?.name || '';
return (
<Tag key={index} icon={<TagsOutlined />}>
{conceptName}
</Tag>
);
})
concepts.slice(0, 3).map((concept, index) => (
<Tag key={index} icon={<TagsOutlined />}>
{typeof concept === 'string'
? concept
: (concept?.concept || concept?.name || '未知')}
</Tag>
))
) : (
<Text type="secondary"></Text>
)}
@@ -948,7 +942,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,49 @@
/**
* 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
py={4}
bg={themeColors.bg}
borderTopLeftRadius={borderRadius}
borderTopRightRadius={borderRadius}
>
{tabs.map((tab, index) => (
<Tab
key={tab.key}
color={themeColors.unselectedText}
borderRadius="full"
px={4}
py={2}
_selected={{
bg: themeColors.selectedBg,
color: themeColors.selectedText,
}}
_hover={{
color: themeColors.selectedText,
}}
mr={index < tabs.length - 1 ? 2 : 0}
>
<HStack spacing={2}>
{tab.icon && <Icon as={tab.icon} boxSize="18px" />}
<Text fontSize="15px">{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,56 @@
/**
* 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,
showDivider: true,
borderRadius: 'lg',
shadow: 'lg',
panelPadding: 0,
};

View File

@@ -0,0 +1,140 @@
/**
* 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,
Divider,
} 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,
showDivider = DEFAULT_CONFIG.showDivider,
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="soft-rounded"
colorScheme="blue"
size={size}
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航 */}
<TabNavigation
tabs={tabs}
themeColors={themeColors}
borderRadius={borderRadius}
/>
{/* 分割线 */}
{showDivider && <Divider borderColor={themeColors.dividerColor} />}
{/* Tab 内容面板 */}
<TabPanels>{renderTabPanels()}</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default TabContainer;

View File

@@ -0,0 +1,87 @@
/**
* 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';
/** 是否显示分割线 */
showDivider?: boolean;
/** 容器圆角 */
borderRadius?: string;
/** 容器阴影 */
shadow?: string;
/** 自定义 Tab 面板内边距 */
panelPadding?: number | string;
/** 子元素(用于自定义渲染 Tab 内容) */
children?: ReactNode;
}
/**
* TabNavigation 组件 Props
*/
export interface TabNavigationProps {
/** Tab 配置数组 */
tabs: TabConfig[];
/** 主题颜色 */
themeColors: Required<ThemeColors>;
/** 容器圆角 */
borderRadius?: string;
}

File diff suppressed because it is too large Load Diff

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

@@ -368,6 +368,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 => {
@@ -380,6 +399,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}`,
@@ -393,7 +417,11 @@ 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 || []
};
});

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

@@ -0,0 +1,635 @@
# Company 目录结构说明
> 最后更新2025-12-11
## 目录结构
```
src/views/Company/
├── index.js # 页面入口(纯组合层)
├── STRUCTURE.md # 本文档
├── components/ # UI 组件
│ │
│ ├── CompanyHeader/ # 页面头部
│ │ ├── index.js # 组合导出
│ │ └── SearchBar.js # 股票搜索栏
│ │
│ ├── CompanyTabs/ # Tab 切换容器
│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染)
│ │ └── TabNavigation.js # Tab 导航栏
│ │
│ ├── StockQuoteCard/ # 股票行情卡片TypeScript
│ │ ├── index.tsx # 主组件
│ │ ├── types.ts # 类型定义
│ │ └── mockData.ts # Mock 数据
│ │
│ ├── CompanyOverview/ # Tab: 公司概览TypeScript
│ │ ├── index.tsx # 主组件(组合层)
│ │ ├── types.ts # 类型定义
│ │ ├── utils.ts # 格式化工具
│ │ ├── DeepAnalysisTab/ # 深度分析 Tab21 个 TS 文件)
│ │ ├── NewsEventsTab.js # 新闻事件 Tab
│ │ │
│ │ ├── hooks/ # 数据 Hooks
│ │ │ ├── useBasicInfo.ts # 基本信息 Hook
│ │ │ ├── useShareholderData.ts # 股权结构 Hook4 APIs
│ │ │ ├── useManagementData.ts # 管理团队 Hook
│ │ │ ├── useAnnouncementsData.ts # 公告数据 Hook
│ │ │ ├── useBranchesData.ts # 分支机构 Hook
│ │ │ ├── useDisclosureData.ts # 披露日程 Hook
│ │ │ └── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
│ │ │
│ │ ├── components/ # 股权结构子组件
│ │ │ └── shareholder/
│ │ │ ├── index.ts # 导出
│ │ │ ├── ActualControlCard.tsx # 实控人卡片
│ │ │ ├── ConcentrationCard.tsx # 股权集中度卡片
│ │ │ └── ShareholdersTable.tsx # 股东表格
│ │ │
│ │ └── BasicInfoTab/ # 基本信息 Tab可配置化
│ │ ├── index.tsx # 主组件(可配置)
│ │ ├── config.ts # Tab 配置 + 黑金主题
│ │ ├── utils.ts # 格式化工具函数
│ │ └── components/ # 子组件
│ │ ├── index.ts # 组件统一导出
│ │ ├── LoadingState.tsx # 加载状态组件
│ │ ├── ShareholderPanel.tsx # 股权结构面板
│ │ ├── AnnouncementsPanel.tsx # 公告信息面板
│ │ ├── BranchesPanel.tsx # 分支机构面板
│ │ ├── BusinessInfoPanel.tsx # 工商信息面板
│ │ ├── DisclosureSchedulePanel.tsx # 披露日程面板
│ │ └── management/ # 管理团队模块
│ │ ├── index.ts # 模块导出
│ │ ├── types.ts # 类型定义
│ │ ├── ManagementPanel.tsx # 主组件useMemo
│ │ ├── CategorySection.tsx # 分类区块memo
│ │ └── ManagementCard.tsx # 人员卡片memo
│ │
│ ├── MarketDataView/ # Tab: 股票行情TypeScript
│ │ ├── index.tsx # 主组件入口
│ │ ├── types.ts # 类型定义
│ │ ├── constants.ts # 主题配置、常量
│ │ ├── services/
│ │ │ └── marketService.ts # API 服务层
│ │ ├── hooks/
│ │ │ └── useMarketData.ts # 数据获取 Hook
│ │ ├── utils/
│ │ │ ├── formatUtils.ts # 格式化工具函数
│ │ │ └── chartOptions.ts # ECharts 图表配置
│ │ └── components/
│ │ ├── index.ts # 组件导出
│ │ ├── ThemedCard.tsx # 主题化卡片
│ │ ├── MarkdownRenderer.tsx # Markdown 渲染
│ │ ├── StockSummaryCard.tsx # 股票概览卡片
│ │ └── AnalysisModal.tsx # 涨幅分析模态框
│ │
│ ├── DeepAnalysis/ # Tab: 深度分析
│ │ └── index.js
│ │
│ ├── DynamicTracking/ # Tab: 动态跟踪
│ │ └── index.js
│ │
│ ├── FinancialPanorama/ # Tab: 财务全景(待拆分)
│ │ └── index.js
│ │
│ └── ForecastReport/ # Tab: 盈利预测(待拆分)
│ └── index.js
├── hooks/ # 页面级 Hooks
│ ├── useCompanyStock.js # 股票代码管理URL 同步)
│ ├── useCompanyWatchlist.js # 自选股管理Redux 集成)
│ ├── useCompanyEvents.js # PostHog 事件追踪
│ └── useStockQuote.js # 股票行情数据 Hook
└── constants/ # 常量定义
└── index.js # Tab 配置、Toast 消息、默认值
```
---
## 文件职责说明
### 入口文件
#### `index.js` - 页面入口
- **职责**:纯组合层,协调 Hooks 和 Components
- **代码行数**95 行
- **依赖**
- `useCompanyStock` - 股票代码状态
- `useCompanyWatchlist` - 自选股状态
- `useCompanyEvents` - 事件追踪
- `CompanyHeader` - 页面头部
- `CompanyTabs` - Tab 切换区
---
### Hooks 目录
#### `useCompanyStock.js` - 股票代码管理
- **功能**
- 管理当前股票代码状态
- 双向同步 URL 参数(支持浏览器前进/后退)
- 处理搜索输入和提交
- **返回值**
```js
{
stockCode, // 当前确认的股票代码
inputCode, // 输入框中的值(未确认)
setInputCode, // 更新输入框
handleSearch, // 执行搜索
handleKeyPress, // 处理回车键
}
```
- **依赖**`react-router-dom` (useSearchParams)
#### `useCompanyWatchlist.js` - 自选股管理
- **功能**
- 检查当前股票是否在自选股中
- 提供添加/移除自选股功能
- 与 Redux stockSlice 同步
- **返回值**
```js
{
isInWatchlist, // 是否在自选股中
isLoading, // 操作进行中
toggle, // 切换自选状态
}
```
- **依赖**Redux (`stockSlice`)、`AuthContext`、Chakra UI (useToast)
#### `useCompanyEvents.js` - 事件追踪
- **功能**
- 页面浏览追踪
- 股票搜索追踪
- Tab 切换追踪
- 自选股操作追踪
- **返回值**
```js
{
trackStockSearched, // 追踪股票搜索
trackTabChanged, // 追踪 Tab 切换
trackWatchlistAdded, // 追踪添加自选
trackWatchlistRemoved, // 追踪移除自选
}
```
- **依赖**PostHog (`usePostHogTrack`)
---
### Components 目录
#### `CompanyHeader/` - 页面头部
| 文件 | 职责 |
|------|------|
| `index.js` | 组合 SearchBar 和 WatchlistButton |
| `SearchBar.js` | 股票代码搜索输入框 |
| `WatchlistButton.js` | 自选股添加/移除按钮 |
**Props 接口**
```js
<CompanyHeader
stockCode={string} // 当前股票代码
inputCode={string} // 输入框值
onInputChange={func} // 输入变化回调
onSearch={func} // 搜索回调
onKeyPress={func} // 键盘事件回调
isInWatchlist={bool} // 是否在自选中
isWatchlistLoading={bool} // 自选操作加载中
onWatchlistToggle={func} // 自选切换回调
bgColor={string} // 背景色
/>
```
#### `CompanyTabs/` - Tab 切换
| 文件 | 职责 |
|------|------|
| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 |
| `TabNavigation.js` | Tab 导航栏4个 Tab 按钮) |
**Props 接口**
```js
<CompanyTabs
stockCode={string} // 当前股票代码
onTabChange={func} // Tab 变更回调 (index, tabName, prevIndex)
bgColor={string} // 背景色
/>
```
---
### Constants 目录
#### `constants/index.js` - 常量配置
- `COMPANY_TABS` - Tab 配置数组key, name, icon
- `TAB_SELECTED_STYLE` - Tab 选中样式
- `TOAST_MESSAGES` - Toast 消息配置
- `DEFAULT_STOCK_CODE` - 默认股票代码 ('000001')
- `URL_PARAM_NAME` - URL 参数名 ('scode')
- `getTabNameByIndex()` - 根据索引获取 Tab 名称
---
### Tab 内容组件(`components/` 目录下)
| 组件 | Tab 名称 | 职责 | 代码行数 |
|------|----------|------|----------|
| `CompanyOverview/` | 公司概览 | 公司基本信息、相关事件 | - |
| `MarketDataView/` | 股票行情 | K线图、实时行情 | - |
| `FinancialPanorama/` | 财务全景 | 财务报表、指标分析 | 2153 行 |
| `ForecastReport/` | 盈利预测 | 分析师预测、目标价 | 161 行 |
> 📌 所有 Tab 内容组件已文件夹化并统一放置在 `components/` 目录下
---
## 数据流示意
```
┌─────────────────────────────────────────────────────────────┐
│ index.js (页面入口) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ useCompanyStock │ │useCompanyWatchlist│ │useCompanyEvents│
│ │ │ │ │ │ │ │
│ │ • stockCode │ │ • isInWatchlist │ │ • track* │ │
│ │ • inputCode │ │ • toggle │ │ functions │ │
│ │ • handleSearch │ │ │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────┬─────────┴───────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyHeader │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ SearchBar │ │ WatchlistButton │ │ │
│ │ └─────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyTabs │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabNavigation │ │ │
│ │ │ [概览] [行情] [财务] [预测] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabPanels │ │ │
│ │ │ • CompanyOverview │ │ │
│ │ │ • MarketDataView │ │ │
│ │ │ • FinancialPanorama │ │ │
│ │ │ • ForecastReport │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 重构记录
### 2025-12-09 重构
**改动概述**
- `index.js` 从 **349 行** 精简至 **95 行**(减少 73%
- 提取 **3 个自定义 Hooks**
- 提取 **2 个组件目录**CompanyHeader、CompanyTabs
- 抽离常量到 `constants/index.js`
**修复的问题**
1. **无限循环 Bug**`useCompanyWatchlist` 中使用 `useRef` 防止重复初始化
2. **Hook 调用顺序**:确保 `useCompanyEvents` 在 `useCompanyStock` 之后调用(依赖 stockCode
3. **类型检查**`CompanyOverview.js` 中 `event.keywords` 渲染时添加类型检查,支持字符串和对象两种格式
**设计原则**
- **关注点分离**:每个 Hook 只负责单一职责
- **纯组合层**index.js 不包含业务逻辑,只负责组合
- **Props 透传**:通过 Props 将状态和回调传递给子组件
### 2025-12-09 文件夹化
**改动概述**
- 所有 4 个 Tab 内容组件统一移动到 `components/` 目录
- `CompanyOverview.js` → `components/CompanyOverview/index.js`
- `MarketDataView.js` → `components/MarketDataView/index.js`
- `FinancialPanorama.js` → `components/FinancialPanorama/index.js`2153 行)
- `ForecastReport.js` → `components/ForecastReport/index.js`161 行)
- 更新 `CompanyTabs/index.js` 中的导入路径
**目的**
- 统一目录结构,所有组件都在 `components/` 下
- 为后期组件拆分做准备便于添加子组件、hooks、utils 等
### 2025-12-10 CompanyOverview 拆分TypeScript
**改动概述**
- `CompanyOverview/index.js` 从 **330 行** 精简至 **50 行**(减少 85%
- 采用 **TypeScript** 进行拆分,提高类型安全性
- 提取 **1 个自定义 Hook**`useCompanyOverviewData`
- 提取 **1 个子组件**`CompanyHeaderCard`
- 抽离类型定义到 `types.ts`
- 抽离工具函数到 `utils.ts`
**拆分后文件结构**
```
CompanyOverview/
├── index.tsx # 主组件(组合层,约 60 行)
├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
├── BasicInfoTab.js # 基本信息 Tab懒加载版本约 994 行)
├── DeepAnalysisTab/ # 深度分析 Tab21 个 TS 文件,见 2025-12-11 重构记录)
├── NewsEventsTab.js # 新闻事件 Tab
├── types.ts # 类型定义(约 50 行)
├── utils.ts # 格式化工具(约 20 行)
└── hooks/
├── useBasicInfo.ts # 基本信息 Hook1 API
├── useShareholderData.ts # 股权结构 Hook4 APIs
├── useManagementData.ts # 管理团队 Hook1 API
├── useAnnouncementsData.ts # 公告数据 Hook1 API
├── useBranchesData.ts # 分支机构 Hook1 API
├── useDisclosureData.ts # 披露日程 Hook1 API
└── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
```
**懒加载架构**2025-12-10 优化):
- `index.tsx` 只加载 `useBasicInfo`1 个 API用于头部卡片
- `BasicInfoTab.js` 使用 `isLazy` + 独立子组件实现懒加载
- 每个内层 Tab 使用独立 Hook点击时才加载数据
**Hooks 说明**
| Hook | API 数量 | 用途 |
|------|----------|------|
| `useBasicInfo` | 1 | 公司基本信息(头部卡片 + 工商信息 Tab |
| `useShareholderData` | 4 | 实控人、股权集中度、十大股东、十大流通股东 |
| `useManagementData` | 1 | 管理团队数据 |
| `useAnnouncementsData` | 1 | 公司公告列表 |
| `useBranchesData` | 1 | 分支机构列表 |
| `useDisclosureData` | 1 | 财报披露日程 |
**类型定义**`types.ts`
- `BasicInfo` - 公司基本信息
- `ActualControl` - 实际控制人
- `Concentration` - 股权集中度
- `Management` - 管理层信息
- `Shareholder` - 股东信息
- `Branch` - 分支机构
- `Announcement` - 公告信息
- `DisclosureSchedule` - 披露计划
- `CompanyOverviewData` - Hook 返回值类型
- `CompanyOverviewProps` - 组件 Props 类型
- `CompanyHeaderCardProps` - 头部卡片 Props 类型
**工具函数**`utils.ts`
- `formatRegisteredCapital(value)` - 格式化注册资本(万元/亿元)
- `formatDate(dateString)` - 格式化日期
**设计原则**
- **渐进式 TypeScript 迁移**:新拆分的文件使用 TypeScript旧文件暂保持 JS
- **关注点分离**:数据加载逻辑提取到 HookUI 逻辑保留在组件
- **类型复用**:统一的类型定义便于在多个文件间共享
- **懒加载优化**:减少首屏 API 请求,按需加载数据
### 2025-12-10 懒加载优化
**改动概述**
- 将 `useCompanyOverviewData`9 个 API拆分为 6 个独立 Hook
- `CompanyOverview/index.tsx` 只加载 `useBasicInfo`1 个 API
- `BasicInfoTab.js` 使用 5 个懒加载子组件,配合 `isLazy` 实现按需加载
- 页面初次加载从 **9 个 API** 减少到 **1 个 API**
**懒加载子组件**BasicInfoTab.js 内部):
| 子组件 | Hook | 功能 |
|--------|------|------|
| `ShareholderTabPanel` | `useShareholderData` | 股权结构4 APIs |
| `ManagementTabPanel` | `useManagementData` | 管理团队 |
| `AnnouncementsTabPanel` | `useAnnouncementsData` + `useDisclosureData` | 公告 + 披露日程 |
| `BranchesTabPanel` | `useBranchesData` | 分支机构 |
| `BusinessInfoTabPanel` | - | 工商信息(使用父组件传入的 basicInfo |
**实现原理**
- Chakra UI `Tabs` 的 `isLazy` 属性延迟渲染 TabPanel
- 每个 TabPanel 使用独立子组件,组件内调用 Hook
- 子组件只在首次激活时渲染,此时 Hook 才执行并发起 API 请求
| Tab 模块 | 中文名称 | 功能说明 |
|-------------------|------|----------------------------|
| CompanyOverview | 公司概览 | 公司基本信息、股权结构、管理层、公告等9个接口 |
| DeepAnalysis | 深度分析 | 公司深度研究报告、投资逻辑分析 |
| MarketDataView | 股票行情 | K线图、实时行情、技术指标 |
| FinancialPanorama | 财务全景 | 财务报表(资产负债表、利润表、现金流)、财务指标分析 |
| ForecastReport | 盈利预测 | 分析师预测、目标价、评级 |
| DynamicTracking | 动态跟踪 | 相关事件、新闻动态、投资日历 |
### 2025-12-10 MarketDataView TypeScript 拆分
**改动概述**
- `MarketDataView/index.js` 从 **2060 行** 拆分为 **12 个 TypeScript 文件**
- 采用 **TypeScript** 进行重构,提高类型安全性
- 提取 **1 个自定义 Hook**`useMarketData`
- 提取 **4 个子组件**ThemedCard、MarkdownRenderer、StockSummaryCard、AnalysisModal
- 抽离 API 服务到 `services/marketService.ts`
- 抽离图表配置到 `utils/chartOptions.ts`
**拆分后文件结构**
```
MarketDataView/
├── index.tsx # 主组件入口(~1049 行)
├── types.ts # 类型定义(~383 行)
├── constants.ts # 主题配置、常量(~49 行)
├── services/
│ └── marketService.ts # API 服务层(~173 行)
├── hooks/
│ └── useMarketData.ts # 数据获取 Hook~193 行)
├── utils/
│ ├── formatUtils.ts # 格式化工具函数(~175 行)
│ └── chartOptions.ts # ECharts 图表配置生成器(~698 行)
└── components/
├── index.ts # 组件导出(~8 行)
├── ThemedCard.tsx # 主题化卡片(~32 行)
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
└── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
```
**文件职责说明**
| 文件 | 行数 | 职责 |
|------|------|------|
| `index.tsx` | ~1049 | 主组件,包含 5 个 Tab 面板(交易数据、融资融券、大宗交易、龙虎榜、股权质押) |
| `types.ts` | ~383 | 所有 TypeScript 类型定义Theme、TradeDayData、MinuteData、FundingData 等) |
| `constants.ts` | ~49 | 主题配置light/dark、周期选项常量 |
| `marketService.ts` | ~173 | API 服务封装getMarketData、getMinuteData、getBigDealData 等) |
| `useMarketData.ts` | ~193 | 数据获取 Hook管理所有市场数据状态 |
| `formatUtils.ts` | ~175 | 数字/日期/涨跌幅格式化工具 |
| `chartOptions.ts` | ~698 | ECharts 配置生成器K线图、分钟图、融资融券图、质押图 |
| `ThemedCard.tsx` | ~32 | 主题化卡片容器组件 |
| `MarkdownRenderer.tsx` | ~65 | Markdown 内容渲染组件 |
| `StockSummaryCard.tsx` | ~133 | 股票概览卡片(价格、涨跌幅、成交量等) |
| `AnalysisModal.tsx` | ~188 | 涨幅分析详情模态框 |
**类型定义**`types.ts`
- `Theme` - 主题配置类型
- `TradeDayData` - 日线交易数据
- `MinuteData` - 分钟线数据
- `FundingDayData` - 融资融券数据
- `BigDealData` / `BigDealDayStats` - 大宗交易数据
- `UnusualData` / `UnusualDayData` - 龙虎榜数据
- `PledgeData` - 股权质押数据
- `RiseAnalysis` - 涨幅分析数据
- `MarketSummary` - 市场概览数据
- `VerificationReport` - 验证报告数据
- 各组件 Props 类型
**Hook 返回值**`useMarketData`
```typescript
{
loading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
fundingData: FundingDayData[];
bigDealData: BigDealData | null;
unusualData: UnusualData | null;
pledgeData: PledgeData | null;
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
}
```
**设计原则**
- **TypeScript 类型安全**:所有数据结构有完整类型定义
- **服务层分离**API 调用统一在 `marketService.ts` 中管理
- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts`
- **组件复用**通用组件ThemedCard、MarkdownRenderer可在其他模块使用
### 2025-12-10 ManagementPanel 拆分重构
**改动概述**
- `ManagementPanel.tsx` 从 **180 行** 拆分为 **5 个 TypeScript 文件**
- 创建 `management/` 子目录,模块化管理
- 添加性能优化(`useMemo`、`React.memo`
**拆分后文件结构**
```
components/management/
├── index.ts # 模块导出
├── types.ts # 类型定义(~35 行)
├── ManagementPanel.tsx # 主组件(~105 行useMemo 优化)
├── CategorySection.tsx # 分类区块组件(~65 行memo
└── ManagementCard.tsx # 人员卡片组件(~100 行memo
```
**类型定义**`types.ts`
- `ManagementPerson` - 管理人员信息
- `ManagementCategory` - 分类类型(高管/董事/监事/其他)
- `CategorizedManagement` - 分类后的数据结构
- `CategoryConfig` - 分类配置(图标、颜色)
**性能优化**
- `useMemo` - 缓存 `categorizeManagement()` 分类计算结果
- `React.memo` - `ManagementCard` 和 `CategorySection` 使用 memo 包装
- 常量提取 - `CATEGORY_CONFIG` 和 `CATEGORY_ORDER` 提取到组件外部
**设计原则**
- **职责分离**:卡片渲染、分类区块、数据处理各自独立
- **类型安全**:消除 `any` 类型,完整的 TypeScript 类型定义
- **可复用性**`ManagementCard` 可独立使用
### 2025-12-11 DeepAnalysisTab 模块化拆分TypeScript
**改动概述**
- `DeepAnalysisTab.js` 从 **1,796 行** 拆分为 **21 个 TypeScript 文件**
- 采用**原子设计模式**atoms/components/organisms组织代码
- 完整 TypeScript 类型定义
- 格式化工具合并到全局 `src/utils/priceFormatters.js`
**拆分后文件结构**
```
DeepAnalysisTab/
├── index.tsx # 主入口组件,组合所有 Card 子组件
├── types.ts # TypeScript 类型定义(接口、数据结构)
├── atoms/ # 原子组件(基础 UI 元素)
│ ├── index.ts # 原子组件统一导出
│ ├── DisclaimerBox.tsx # 免责声明警告框(黄色 Alert用 6 次)
│ ├── ScoreBar.tsx # 评分进度条(带颜色渐变,用 8 次)
│ ├── BusinessTreeItem.tsx # 业务结构树形项(递归组件)
│ └── KeyFactorCard.tsx # 关键因素卡片(带影响方向图标)
├── components/ # Card 容器组件(页面区块)
│ ├── index.ts # Card 组件统一导出
│ ├── CorePositioningCard.tsx # 核心定位卡片(行业地位、核心优势)
│ ├── CompetitiveAnalysisCard.tsx # 竞争地位分析卡片(雷达图 + 评分条)
│ ├── BusinessStructureCard.tsx # 业务结构分析卡片(树形展示)
│ ├── ValueChainCard.tsx # 产业链分析卡片Tabs: 上游/中游/下游)
│ ├── KeyFactorsCard.tsx # 关键因素卡片Accordion 折叠面板)
│ ├── TimelineCard.tsx # 发展时间线卡片(正面/负面事件统计)
│ ├── BusinessSegmentsCard.tsx # 业务板块详情卡片(可展开/折叠)
│ └── StrategyAnalysisCard.tsx # 战略分析卡片(战略方向 + 战略举措)
├── organisms/ # 复杂组件(含状态管理和 API 调用)
│ ├── ValueChainNodeCard/ # 产业链节点组件
│ │ ├── index.tsx # 产业链节点卡片(点击展开详情)
│ │ └── RelatedCompaniesModal.tsx # 相关公司模态框API 获取公司列表)
│ └── TimelineComponent/ # 时间线组件
│ ├── index.tsx # 时间线主组件(事件列表渲染)
│ └── EventDetailModal.tsx # 事件详情模态框(查看完整事件信息)
└── utils/
└── chartOptions.ts # ECharts 图表配置(雷达图、桑基图)
```
**组件依赖关系**
```
index.tsx
├── CorePositioningCard
├── CompetitiveAnalysisCard
│ ├── ScoreBar (atom)
│ ├── DisclaimerBox (atom)
│ └── ReactECharts (雷达图)
├── BusinessStructureCard
│ └── BusinessTreeItem (atom, 递归)
├── ValueChainCard
│ └── ValueChainNodeCard (organism)
│ └── RelatedCompaniesModal
├── KeyFactorsCard
│ └── KeyFactorCard (atom)
├── TimelineCard
│ └── TimelineComponent (organism)
│ └── EventDetailModal
├── BusinessSegmentsCard
└── StrategyAnalysisCard
└── DisclaimerBox (atom)
```
**类型定义**`types.ts`
- `DeepAnalysisTabProps` - 主组件 Props
- `QualitativeAnalysis` - 定性分析数据
- `CompetitivePosition` - 竞争地位数据
- `BusinessStructureItem` - 业务结构项
- `ValueChainData` - 产业链数据
- `ValueChainNode` - 产业链节点
- `KeyFactor` - 关键因素
- `DevelopmentTimeline` - 发展时间线
- `TimelineEvent` - 时间线事件
- `BusinessSegment` - 业务板块
- `Strategy` - 战略分析
**工具函数位置**
| 函数 | 文件位置 | 说明 |
|------|----------|------|
| `formatCurrency` | `src/utils/priceFormatters.js` | 货币格式化 |
| `formatBusinessRevenue` | `src/utils/priceFormatters.js` | 营收格式化(亿/万) |
| `formatPercentage` | `src/utils/priceFormatters.js` | 百分比格式化 |
| `getRadarChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 雷达图 ECharts 配置 |
| `getSankeyChartOption` | `DeepAnalysisTab/utils/chartOptions.ts` | 桑基图 ECharts 配置 |
**优化效果**
| 指标 | 优化前 | 优化后 | 改善 |
|------|--------|--------|------|
| 主文件行数 | 1,796 | ~117 | -93% |
| 文件数量 | 1 (.js) | 21 (.tsx/.ts) | 模块化 + TS |
| 可复用组件 | 0 | 4 原子 + 2 复杂 | 提升 |
| 类型安全 | 无 | 完整 | TypeScript |
**设计原则**
- **原子设计模式**atoms基础元素→ components区块→ organisms复杂交互
- **TypeScript 类型安全**:完整的接口定义,消除 any 类型
- **职责分离**UI 渲染与 API 调用分离,模态框独立管理
- **代码复用**DisclaimerBox、ScoreBar 等原子组件多处复用

View File

@@ -0,0 +1,155 @@
// 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';
/**
* 股票搜索栏组件(带模糊搜索下拉)
*
* @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 [filteredStocks, setFilteredStocks] = useState([]);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 模糊搜索过滤
useEffect(() => {
if (inputCode && inputCode.trim()) {
const searchTerm = inputCode.trim().toLowerCase();
const filtered = allStocks.filter(stock =>
stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputCode.trim())
).slice(0, 10); // 限制显示10条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputCode, allStocks]);
// 点击外部关闭下拉
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,163 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
// 公司公告 Tab Panel
import React, { useState } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
IconButton,
Button,
Tag,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { FaBullhorn } from "react-icons/fa";
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>
<HStack mb={3}>
<Icon as={FaBullhorn} color={THEME.gold} />
<Text fontWeight="bold" color={THEME.textPrimary}></Text>
</HStack>
<VStack spacing={2} align="stretch">
{announcements.map((announcement: any, idx: number) => (
<Card
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,109 @@
// 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,
} from "@chakra-ui/react";
import { THEME } from "../config";
interface BusinessInfoPanelProps {
basicInfo: any;
}
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ basicInfo }) => {
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,83 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
// 财报披露日程 Tab Panel
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Card,
CardBody,
SimpleGrid,
} from "@chakra-ui/react";
import { FaCalendarAlt } from "react-icons/fa";
import { useDisclosureData } from "../../hooks/useDisclosureData";
import { THEME } from "../config";
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>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color={THEME.gold} />
<Text fontWeight="bold" color={THEME.textPrimary}></Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.map((schedule: any, idx: number) => (
<Card
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,64 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
// 股权结构 Tab Panel - 使用拆分后的子组件
import React from "react";
import { VStack, SimpleGrid, Box } from "@chakra-ui/react";
import { useShareholderData } from "../../hooks/useShareholderData";
import {
ActualControlCard,
ConcentrationCard,
ShareholdersTable,
} from "../../components/shareholder";
import LoadingState from "./LoadingState";
interface ShareholderPanelProps {
stockCode: string;
}
/**
* 股权结构面板
* 使用拆分后的子组件:
* - ActualControlCard: 实际控制人卡片
* - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
if (loading) {
return <LoadingState message="加载股权结构数据..." />;
}
return (
<VStack spacing={6} align="stretch">
{/* 实际控制人 + 股权集中度 左右分布 */}
<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>
</VStack>
);
};
export default ShareholderPanel;

View File

@@ -0,0 +1,9 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
// 组件导出
export { default as LoadingState } from "./LoadingState";
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,105 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
// 管理团队 Tab Panel重构版
import React, { useMemo } from "react";
import { VStack } from "@chakra-ui/react";
import {
FaUserTie,
FaCrown,
FaEye,
FaUsers,
} from "react-icons/fa";
import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config";
import LoadingState from "../LoadingState";
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]
);
if (loading) {
return <LoadingState message="加载管理团队数据..." />;
}
return (
<VStack spacing={6} align="stretch">
{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}
/>
);
})}
</VStack>
);
};
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,145 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
// 基本信息 Tab 组件 - 可配置版本(黑金主题)
import React from "react";
import {
Card,
CardBody,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
HStack,
Text,
} from "@chakra-ui/react";
import { THEME, TAB_CONFIG, getEnabledTabs, type TabConfig } from "./config";
import {
ShareholderPanel,
ManagementPanel,
AnnouncementsPanel,
BranchesPanel,
BusinessInfoPanel,
} from "./components";
// Props 类型定义
export interface BasicInfoTabProps {
stockCode: string;
basicInfo?: any;
// 可配置项
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,
};
/**
* 基本信息 Tab 组件
*
* 特性:
* - 可配置显示哪些 TabenabledTabs
* - 黑金主题
* - 懒加载isLazy
* - 支持 Tab 变更回调
*/
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
stockCode,
basicInfo,
enabledTabs,
defaultTabIndex = 0,
onTabChange,
}) => {
// 获取启用的 Tab 配置
const tabs = getEnabledTabs(enabledTabs);
// 处理 Tab 变更
const handleTabChange = (index: number) => {
if (onTabChange && tabs[index]) {
onTabChange(index, tabs[index].key);
}
};
// 渲染单个 Tab 内容
const renderTabContent = (tab: TabConfig) => {
const Component = TAB_COMPONENTS[tab.key];
if (!Component) return null;
// business Tab 需要 basicInfo其他需要 stockCode
if (tab.key === "business") {
return <Component basicInfo={basicInfo} />;
}
return <Component stockCode={stockCode} />;
};
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<Tabs
isLazy
variant="unstyled"
defaultIndex={defaultTabIndex}
onChange={handleTabChange}
>
<TabList
bg={THEME.bg}
borderBottom="1px solid"
borderColor={THEME.border}
px={4}
py={2}
flexWrap="wrap"
gap={2}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={THEME.tabUnselected.color}
borderRadius="full"
px={4}
py={2}
fontSize="sm"
_selected={{
bg: THEME.tabSelected.bg,
color: THEME.tabSelected.color,
fontWeight: "bold",
}}
_hover={{
bg: THEME.tableHoverBg,
}}
>
<HStack spacing={2}>
<Icon as={tab.icon} boxSize={4} />
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
<TabPanels p={4}>
{tabs.map((tab) => (
<TabPanel key={tab.key} p={0}>
{renderTabContent(tab)}
</TabPanel>
))}
</TabPanels>
</Tabs>
</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,86 @@
/**
* 业务结构树形项组件
*
* 递归显示业务结构层级
* 使用位置:业务结构分析卡片
*/
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 BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
const bgColor = 'gray.50';
// 获取营收显示
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={bgColor}
borderLeft={depth > 0 ? '4px solid' : 'none'}
borderLeftColor="blue.400"
borderRadius="md"
mb={2}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'}>
{business.business_name}
</Text>
{business.financial_metrics?.revenue_ratio &&
business.financial_metrics.revenue_ratio > 30 && (
<Badge colorScheme="purple" size="sm">
</Badge>
)}
</HStack>
<HStack spacing={4} flexWrap="wrap">
<Tag size="sm" variant="subtle">
: {formatPercentage(business.financial_metrics?.revenue_ratio)}
</Tag>
<Tag size="sm" variant="subtle">
: {formatPercentage(business.financial_metrics?.gross_margin)}
</Tag>
{business.growth_metrics?.revenue_growth !== undefined && (
<Tag
size="sm"
colorScheme={
business.growth_metrics.revenue_growth > 0 ? 'red' : 'green'
}
>
<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="blue.500">
{getRevenueDisplay()}
</Text>
<Text fontSize="xs" color="gray.500">
</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,108 @@
/**
* 关键因素卡片组件
*
* 显示单个关键因素的详细信息
* 使用位置:关键因素 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 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);
const bgColor = 'white';
const borderColor = 'gray.200';
return (
<Card bg={bgColor} borderColor={borderColor} size="sm">
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontWeight="medium" fontSize="sm">
{factor.factor_name}
</Text>
<Badge colorScheme={impactColor} size="sm">
{getImpactLabel(factor.impact_direction)}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.500`}>
{factor.factor_value}
{factor.factor_unit && ` ${factor.factor_unit}`}
</Text>
{factor.year_on_year !== undefined && (
<Tag
size="sm"
colorScheme={factor.year_on_year > 0 ? 'red' : 'green'}
>
<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="gray.600" noOfLines={2}>
{factor.factor_desc}
</Text>
)}
<HStack justify="space-between">
<Text fontSize="xs" color="gray.500">
: {factor.impact_weight}
</Text>
{factor.report_period && (
<Text fontSize="xs" color="gray.500">
{factor.report_period}
</Text>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
export default KeyFactorCard;

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,10 @@
/**
* 原子组件导出
*
* 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';

View File

@@ -0,0 +1,157 @@
/**
* 业务板块详情卡片
*
* 显示公司各业务板块的详细信息
*/
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 { DisclaimerBox } from '../atoms';
import type { BusinessSegment } from '../types';
interface BusinessSegmentsCardProps {
businessSegments: BusinessSegment[];
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
cardBg?: string;
}
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
businessSegments,
expandedSegments,
onToggleSegment,
cardBg,
}) => {
if (!businessSegments || businessSegments.length === 0) return null;
return (
<Card bg={cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaIndustry} color="indigo.500" />
<Heading size="sm"></Heading>
<Badge>{businessSegments.length} </Badge>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{businessSegments.map((segment, idx) => {
const isExpanded = expandedSegments[idx];
return (
<Card key={idx} variant="outline">
<CardBody>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="bold" fontSize="md">
{segment.segment_name}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
}
onClick={() => onToggleSegment(idx)}
colorScheme="blue"
>
{isExpanded ? '折叠' : '展开'}
</Button>
</HStack>
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 3}
>
{segment.segment_description || '暂无描述'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
>
{segment.competitive_position || '暂无数据'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
color="blue.600"
>
{segment.future_potential || '暂无数据'}
</Text>
</Box>
{isExpanded && segment.key_products && (
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Text fontSize="sm" color="green.600">
{segment.key_products}
</Text>
</Box>
)}
{isExpanded && segment.market_share !== undefined && (
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Badge colorScheme="purple" fontSize="sm">
{segment.market_share}%
</Badge>
</Box>
)}
{isExpanded && segment.revenue_contribution !== undefined && (
<Box>
<Text fontSize="xs" color="gray.500" mb={1}>
</Text>
<Badge colorScheme="orange" fontSize="sm">
{segment.revenue_contribution}%
</Badge>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
</CardBody>
</Card>
);
};
export default BusinessSegmentsCard;

View File

@@ -0,0 +1,58 @@
/**
* 业务结构分析卡片
*
* 显示公司业务结构树形图
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Icon,
} from '@chakra-ui/react';
import { FaChartPie } from 'react-icons/fa';
import { DisclaimerBox, BusinessTreeItem } from '../atoms';
import type { BusinessStructure } from '../types';
interface BusinessStructureCardProps {
businessStructure: BusinessStructure[];
cardBg?: string;
}
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
businessStructure,
cardBg,
}) => {
if (!businessStructure || businessStructure.length === 0) return null;
return (
<Card bg={cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaChartPie} color="purple.500" />
<Heading size="sm"></Heading>
<Badge>{businessStructure[0]?.report_period}</Badge>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<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,219 @@
/**
* 竞争地位分析卡片
*
* 显示竞争力评分、雷达图和竞争分析
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Tag,
TagLabel,
Grid,
GridItem,
Box,
Icon,
Divider,
SimpleGrid,
} from '@chakra-ui/react';
import {
FaTrophy,
FaCog,
FaStar,
FaChartLine,
FaDollarSign,
FaFlask,
FaShieldAlt,
FaRocket,
FaUsers,
} from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { ScoreBar } from '../atoms';
import { getRadarChartOption } from '../utils/chartOptions';
import type { ComprehensiveData, CompetitivePosition } from '../types';
// 样式常量 - 避免每次渲染创建新对象
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;
}
// 竞争对手标签组件
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 }) => {
const competitivePosition = comprehensiveData.competitive_position;
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]
);
return (
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
{competitivePosition.ranking && (
<Badge
ml={2}
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
>
{competitivePosition.ranking.industry_rank}/
{competitivePosition.ranking.total_companies}
</Badge>
)}
</HStack>
</CardHeader>
<CardBody>
{/* 主要竞争对手 */}
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
{/* 评分和雷达图 */}
<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>
);
}
);
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,78 @@
/**
* 关键因素卡片
*
* 显示影响公司的关键因素列表
*/
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 { DisclaimerBox, KeyFactorCard } from '../atoms';
import type { KeyFactors } from '../types';
interface KeyFactorsCardProps {
keyFactors: KeyFactors;
cardBg?: string;
}
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({
keyFactors,
cardBg,
}) => {
return (
<Card bg={cardBg} shadow="md" h="full">
<CardHeader>
<HStack>
<Icon as={FaBalanceScale} color="orange.500" />
<Heading size="sm"></Heading>
<Badge>{keyFactors.total_factors} </Badge>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<Accordion allowMultiple>
{keyFactors.categories.map((category, idx) => (
<AccordionItem key={idx}>
<AccordionButton>
<Box flex="1" textAlign="left">
<HStack>
<Text fontWeight="medium">{category.category_name}</Text>
<Badge size="sm" variant="subtle">
{category.factors.length}
</Badge>
</HStack>
</Box>
<AccordionIcon />
</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,58 @@
/**
* 发展时间线卡片
*
* 显示公司发展历程时间线
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Heading,
Badge,
Box,
Icon,
} from '@chakra-ui/react';
import { FaHistory } from 'react-icons/fa';
import { DisclaimerBox } from '../atoms';
import TimelineComponent from '../organisms/TimelineComponent';
import type { DevelopmentTimeline } from '../types';
interface TimelineCardProps {
developmentTimeline: DevelopmentTimeline;
cardBg?: string;
}
const TimelineCard: React.FC<TimelineCardProps> = ({
developmentTimeline,
cardBg,
}) => {
return (
<Card bg={cardBg} shadow="md" h="full">
<CardHeader>
<HStack>
<Icon as={FaHistory} color="cyan.500" />
<Heading size="sm">线</Heading>
<HStack spacing={1}>
<Badge colorScheme="red">
{developmentTimeline.statistics?.positive_events || 0}
</Badge>
<Badge colorScheme="green">
{developmentTimeline.statistics?.negative_events || 0}
</Badge>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<Box maxH="600px" overflowY="auto" pr={2}>
<TimelineComponent events={developmentTimeline.events} />
</Box>
</CardBody>
</Card>
);
};
export default TimelineCard;

View File

@@ -0,0 +1,185 @@
/**
* 产业链分析卡片
*
* 显示产业链层级视图和流向关系
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
SimpleGrid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Center,
} from '@chakra-ui/react';
import { FaNetworkWired } from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { DisclaimerBox } from '../atoms';
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
import { getSankeyChartOption } from '../utils/chartOptions';
import type { ValueChainData } from '../types';
interface ValueChainCardProps {
valueChainData: ValueChainData;
cardBg?: string;
}
const ValueChainCard: React.FC<ValueChainCardProps> = ({
valueChainData,
cardBg,
}) => {
const sankeyOption = getSankeyChartOption(valueChainData);
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
// 获取上游节点
const upstreamNodes = [
...(nodesByLevel?.['level_-2'] || []),
...(nodesByLevel?.['level_-1'] || []),
];
// 获取核心节点
const coreNodes = nodesByLevel?.['level_0'] || [];
// 获取下游节点
const downstreamNodes = [
...(nodesByLevel?.['level_1'] || []),
...(nodesByLevel?.['level_2'] || []),
];
return (
<Card bg={cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaNetworkWired} color="teal.500" />
<Heading size="sm"></Heading>
<HStack spacing={2}>
<Badge colorScheme="orange">
{valueChainData.analysis_summary?.upstream_nodes || 0}
</Badge>
<Badge colorScheme="blue">
{valueChainData.analysis_summary?.company_nodes || 0}
</Badge>
<Badge colorScheme="green">
{valueChainData.analysis_summary?.downstream_nodes || 0}
</Badge>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<DisclaimerBox />
<Tabs variant="soft-rounded" colorScheme="teal">
<TabList>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
{/* 层级视图 */}
<TabPanel>
<VStack spacing={8} align="stretch">
{/* 上游供应链 */}
{upstreamNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{upstreamNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
level={node.node_level}
/>
))}
</SimpleGrid>
</Box>
)}
{/* 核心企业 */}
{coreNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{coreNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={0}
/>
))}
</SimpleGrid>
</Box>
)}
{/* 下游客户 */}
{downstreamNodes.length > 0 && (
<Box>
<HStack mb={4}>
<Badge colorScheme="green" fontSize="md" px={3} py={1}>
</Badge>
<Text fontSize="sm" color="gray.600">
</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{downstreamNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
level={node.node_level}
/>
))}
</SimpleGrid>
</Box>
)}
</VStack>
</TabPanel>
{/* 流向关系 */}
<TabPanel>
{sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="light"
/>
) : (
<Center h="200px">
<Text color="gray.500"></Text>
</Center>
)}
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
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,114 @@
/**
* 深度分析 Tab 主组件
*
* 组合所有子组件,显示公司深度分析内容
*/
import React from 'react';
import { VStack, Center, Text, Spinner, Grid, GridItem } from '@chakra-ui/react';
import {
CorePositioningCard,
CompetitiveAnalysisCard,
BusinessStructureCard,
ValueChainCard,
KeyFactorsCard,
TimelineCard,
BusinessSegmentsCard,
StrategyAnalysisCard,
} from './components';
import type { DeepAnalysisTabProps } from './types';
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
comprehensiveData,
valueChainData,
keyFactorsData,
loading,
cardBg,
expandedSegments,
onToggleSegment,
}) => {
// 加载状态
if (loading) {
return (
<Center h="200px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>...</Text>
</VStack>
</Center>
);
}
return (
<VStack spacing={6} align="stretch">
{/* 核心定位卡片 */}
{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} />
)}
{/* 业务结构分析 */}
{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}
/>
)}
{/* 产业链分析 */}
{valueChainData && (
<ValueChainCard valueChainData={valueChainData} cardBg={cardBg} />
)}
{/* 关键因素与发展时间线 */}
<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>
</VStack>
);
};
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,234 @@
/**
* 产业链节点卡片组件
*
* 显示产业链中的单个节点,点击可展开查看相关公司
*/
import React, { useState } 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 { getApiBase } from '@utils/apiConfig';
import RelatedCompaniesModal from './RelatedCompaniesModal';
import type { ValueChainNodeCardProps, RelatedCompany } from '../../types';
const API_BASE_URL = getApiBase();
/**
* 获取节点类型对应的图标
*/
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 ValueChainNodeCard: React.FC<ValueChainNodeCardProps> = ({
node,
isCompany = false,
level = 0,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [relatedCompanies, setRelatedCompanies] = useState<RelatedCompany[]>([]);
const [loadingRelated, setLoadingRelated] = useState(false);
const toast = useToast();
// 根据层级和是否为核心企业确定颜色方案
const getColorScheme = (): string => {
if (isCompany) return 'blue';
if (level < 0) return 'orange';
if (level > 0) return 'green';
return 'gray';
};
const colorScheme = getColorScheme();
const bgColor = `${colorScheme}.50`;
const borderColor = `${colorScheme}.200`;
// 获取相关公司数据
const fetchRelatedCompanies = async () => {
setLoadingRelated(true);
try {
const response = await fetch(
`${API_BASE_URL}/api/company/value-chain/related-companies?node_name=${encodeURIComponent(
node.node_name
)}`
);
const data = await response.json();
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={bgColor}
borderColor={borderColor}
borderWidth={isCompany ? 3 : 1}
shadow={isCompany ? 'lg' : 'sm'}
cursor="pointer"
onClick={handleCardClick}
_hover={{
shadow: 'xl',
transform: 'translateY(-4px)',
borderColor: `${colorScheme}.400`,
}}
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={`${colorScheme}.500`}
boxSize={5}
/>
{isCompany && (
<Badge colorScheme="blue" variant="solid">
</Badge>
)}
</HStack>
{node.importance_score !== undefined &&
node.importance_score >= 70 && (
<Tooltip label="重要节点">
<span>
<Icon as={FaStar} color="orange.400" boxSize={4} />
</span>
</Tooltip>
)}
</HStack>
<Text fontWeight="bold" fontSize="sm" noOfLines={2}>
{node.node_name}
</Text>
{node.node_description && (
<Text fontSize="xs" color="gray.600" noOfLines={2}>
{node.node_description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
<Badge variant="subtle" size="sm" colorScheme={colorScheme}>
{node.node_type}
</Badge>
{node.market_share !== undefined && (
<Badge variant="outline" size="sm">
{node.market_share}%
</Badge>
)}
</HStack>
{node.importance_score !== undefined && (
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color="gray.500">
</Text>
<Text fontSize="xs" fontWeight="bold">
{node.importance_score}
</Text>
</HStack>
<Progress
value={node.importance_score}
size="xs"
colorScheme={getImportanceColor(node.importance_score)}
borderRadius="full"
/>
</Box>
)}
</VStack>
</CardBody>
</Card>
</ScaleFade>
<RelatedCompaniesModal
isOpen={isOpen}
onClose={onClose}
node={node}
isCompany={isCompany}
colorScheme={colorScheme}
relatedCompanies={relatedCompanies}
loadingRelated={loadingRelated}
/>
</>
);
};
export default ValueChainNodeCard;

View File

@@ -0,0 +1,365 @@
/**
* 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;
}
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;
}
// ==================== 主组件 Props 类型 ====================
export interface DeepAnalysisTabProps {
comprehensiveData?: ComprehensiveData;
valueChainData?: ValueChainData;
keyFactorsData?: KeyFactorsData;
loading?: boolean;
cardBg?: string;
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => 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,537 @@
// 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";
/**
* 新闻动态 Tab 组件
*
* Props:
* - newsEvents: 新闻事件列表数组
* - newsLoading: 加载状态
* - newsPagination: 分页信息 { page, per_page, total, pages, has_next, has_prev }
* - searchQuery: 搜索关键词
* - onSearchChange: 搜索输入回调 (value) => void
* - onSearch: 搜索提交回调 () => void
* - onPageChange: 分页回调 (page) => void
* - cardBg: 卡片背景色
*/
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,
}) => {
// 事件类型图标映射
const getEventTypeIcon = (eventType) => {
const iconMap = {
企业公告: FaBullhorn,
政策: FaGavel,
技术突破: FaFlask,
企业融资: FaDollarSign,
政策监管: FaShieldAlt,
政策动态: FaFileAlt,
行业事件: FaIndustry,
};
return iconMap[eventType] || FaNewspaper;
};
// 重要性颜色映射
const getImportanceColor = (importance) => {
const colorMap = {
S: "red",
A: "orange",
B: "yellow",
C: "green",
};
return 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="gray.400">
...
</Text>
);
}
for (let i = startPage; i <= endPage; i++) {
pageButtons.push(
<Button
key={i}
size="sm"
variant={i === currentPage ? "solid" : "outline"}
colorScheme={i === currentPage ? "blue" : "gray"}
onClick={() => handlePageChange(i)}
isDisabled={newsLoading}
>
{i}
</Button>
);
}
// 如果结束页小于总页数,显示省略号
if (endPage < totalPages) {
pageButtons.push(
<Text key="end-ellipsis" fontSize="sm" color="gray.400">
...
</Text>
);
}
return pageButtons;
};
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} shadow="md">
<CardBody>
<VStack spacing={4} align="stretch">
{/* 搜索框和统计信息 */}
<HStack justify="space-between" flexWrap="wrap">
<HStack flex={1} minW="300px">
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
placeholder="搜索相关新闻..."
value={searchQuery}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
/>
</InputGroup>
<Button
colorScheme="blue"
onClick={handleSearchSubmit}
isLoading={newsLoading}
minW="80px"
>
搜索
</Button>
</HStack>
{newsPagination.total > 0 && (
<HStack spacing={2}>
<Icon as={FaNewspaper} color="blue.500" />
<Text fontSize="sm" color="gray.600">
共找到{" "}
<Text as="span" fontWeight="bold" color="blue.600">
{newsPagination.total}
</Text>{" "}
条新闻
</Text>
</HStack>
)}
</HStack>
<div id="news-list-top" />
{/* 新闻列表 */}
{newsLoading ? (
<Center h="400px">
<VStack spacing={3}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.600">正在加载新闻...</Text>
</VStack>
</Center>
) : newsEvents.length > 0 ? (
<>
<VStack spacing={3} align="stretch">
{newsEvents.map((event, idx) => {
const importanceColor = getImportanceColor(
event.importance
);
const eventTypeIcon = getEventTypeIcon(event.event_type);
return (
<Card
key={event.id || idx}
variant="outline"
_hover={{
bg: "gray.50",
shadow: "md",
borderColor: "blue.300",
}}
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="blue.500"
boxSize={5}
/>
<Text
fontWeight="bold"
fontSize="lg"
lineHeight="1.3"
>
{event.title}
</Text>
</HStack>
{/* 标签栏 */}
<HStack spacing={2} flexWrap="wrap">
{event.importance && (
<Badge
colorScheme={importanceColor}
variant="solid"
px={2}
>
{event.importance}
</Badge>
)}
{event.event_type && (
<Badge colorScheme="blue" variant="outline">
{event.event_type}
</Badge>
)}
{event.invest_score && (
<Badge
colorScheme="purple"
variant="subtle"
>
投资分: {event.invest_score}
</Badge>
)}
{event.keywords && event.keywords.length > 0 && (
<>
{event.keywords
.slice(0, 4)
.map((keyword, kidx) => (
<Tag
key={kidx}
size="sm"
colorScheme="cyan"
variant="subtle"
>
{typeof keyword === "string"
? keyword
: keyword?.concept ||
keyword?.name ||
"未知"}
</Tag>
))}
</>
)}
</HStack>
</VStack>
{/* 右侧信息栏 */}
<VStack align="end" spacing={1} minW="100px">
<Text fontSize="xs" color="gray.500">
{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="gray.400"
/>
<Text fontSize="xs" color="gray.500">
{event.view_count}
</Text>
</HStack>
)}
{event.hot_score !== undefined && (
<HStack spacing={1}>
<Icon
as={FaFire}
boxSize={3}
color="orange.400"
/>
<Text fontSize="xs" color="gray.500">
{event.hot_score.toFixed(1)}
</Text>
</HStack>
)}
</HStack>
{event.creator && (
<Text fontSize="xs" color="gray.400">
@{event.creator.username}
</Text>
)}
</VStack>
</HStack>
{/* 描述 */}
{event.description && (
<Text
fontSize="sm"
color="gray.700"
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="gray.200"
>
<HStack spacing={6} flexWrap="wrap">
<HStack spacing={1}>
<Icon
as={FaChartLine}
boxSize={3}
color="gray.500"
/>
<Text
fontSize="xs"
color="gray.500"
fontWeight="medium"
>
相关涨跌:
</Text>
</HStack>
{event.related_avg_chg !== null &&
event.related_avg_chg !== undefined && (
<HStack spacing={1}>
<Text fontSize="xs" color="gray.500">
平均
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_avg_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.500">
最大
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_max_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.500">
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={
event.related_week_chg > 0
? "red.500"
: "green.500"
}
>
{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="gray.600">
{newsPagination.page} / {newsPagination.pages}
</Text>
{/* 分页按钮 */}
<HStack spacing={2}>
<Button
size="sm"
onClick={() => handlePageChange(1)}
isDisabled={!newsPagination.has_prev || newsLoading}
leftIcon={<Icon as={FaChevronLeft} />}
>
首页
</Button>
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page - 1)
}
isDisabled={!newsPagination.has_prev || newsLoading}
>
上一页
</Button>
{/* 页码按钮 */}
{renderPaginationButtons()}
<Button
size="sm"
onClick={() =>
handlePageChange(newsPagination.page + 1)
}
isDisabled={!newsPagination.has_next || newsLoading}
>
下一页
</Button>
<Button
size="sm"
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="gray.300" />
<Text color="gray.500" fontSize="lg" fontWeight="medium">
暂无相关新闻
</Text>
<Text fontSize="sm" color="gray.400">
{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,61 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Announcement } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/announcements?limit=20`
);
const result = (await response.json()) as ApiResponse<Announcement[]>;
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
}
} catch (err) {
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { announcements, loading, error };
};

View File

@@ -0,0 +1,59 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { BasicInfo } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/basic-info`);
const result = (await response.json()) as ApiResponse<BasicInfo>;
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
}
} catch (err) {
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { basicInfo, loading, error };
};

View File

@@ -0,0 +1,59 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Branch } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/stock/${stockCode}/branches`);
const result = (await response.json()) as ApiResponse<Branch[]>;
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
}
} catch (err) {
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { branches, loading, error };
};

View File

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

View File

@@ -0,0 +1,61 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { DisclosureSchedule } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/disclosure-schedule`
);
const result = (await response.json()) as ApiResponse<DisclosureSchedule[]>;
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
}
} catch (err) {
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { disclosureSchedule, loading, error };
};

View File

@@ -0,0 +1,61 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { Management } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${API_BASE_URL}/api/stock/${stockCode}/management?active_only=true`
);
const result = (await response.json()) as ApiResponse<Management[]>;
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
}
} catch (err) {
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return { management, loading, error };
};

View File

@@ -0,0 +1,83 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect, useCallback } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
import type { ActualControl, Concentration, Shareholder } from "../types";
const API_BASE_URL = getApiBase();
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);
const loadData = useCallback(async () => {
if (!stockCode) return;
setLoading(true);
setError(null);
try {
const [actualRes, concentrationRes, shareholdersRes, circulationRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/stock/${stockCode}/actual-control`).then((r) =>
r.json()
) as Promise<ApiResponse<ActualControl[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/concentration`).then((r) =>
r.json()
) as Promise<ApiResponse<Concentration[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
fetch(`${API_BASE_URL}/api/stock/${stockCode}/top-circulation-shareholders?limit=10`).then((r) =>
r.json()
) as Promise<ApiResponse<Shareholder[]>>,
]);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err) {
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData();
}, [loadData]);
return {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
error,
};
};

View File

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

View File

@@ -0,0 +1,133 @@
// 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;
}
/**
* 实际控制人
*/
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;
}
/**
* useCompanyOverviewData Hook 返回值
*/
export interface CompanyOverviewData {
basicInfo: BasicInfo | null;
actualControl: ActualControl[];
concentration: Concentration[];
management: Management[];
topCirculationShareholders: Shareholder[];
topShareholders: Shareholder[];
branches: Branch[];
announcements: Announcement[];
disclosureSchedule: DisclosureSchedule[];
loading: boolean;
dataLoaded: boolean;
}
/**
* CompanyOverview 组件 Props
*/
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,100 @@
// src/views/Company/components/DeepAnalysis/index.js
// 深度分析 - 独立一级 Tab 组件
import React, { useState, useEffect } from "react";
import { logger } from "@utils/logger";
import { getApiBase } from "@utils/apiConfig";
// 复用原有的展示组件
import DeepAnalysisTab from "../CompanyOverview/DeepAnalysisTab";
const API_BASE_URL = getApiBase();
/**
* 深度分析组件
*
* 功能:
* - 加载深度分析数据3个接口
* - 管理展开状态
* - 渲染 DeepAnalysisTab 展示组件
*
* @param {Object} props
* @param {string} props.stockCode - 股票代码
*/
const DeepAnalysis = ({ stockCode }) => {
// 数据状态
const [comprehensiveData, setComprehensiveData] = useState(null);
const [valueChainData, setValueChainData] = useState(null);
const [keyFactorsData, setKeyFactorsData] = useState(null);
const [loading, setLoading] = useState(false);
// 业务板块展开状态
const [expandedSegments, setExpandedSegments] = useState({});
// 切换业务板块展开状态
const toggleSegmentExpansion = (segmentIndex) => {
setExpandedSegments((prev) => ({
...prev,
[segmentIndex]: !prev[segmentIndex],
}));
};
// 加载深度分析数据3个接口
const loadDeepAnalysisData = async () => {
if (!stockCode) return;
setLoading(true);
try {
const requests = [
fetch(
`${API_BASE_URL}/api/company/comprehensive-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/value-chain-analysis/${stockCode}`
).then((r) => r.json()),
fetch(
`${API_BASE_URL}/api/company/key-factors-timeline/${stockCode}`
).then((r) => r.json()),
];
const [comprehensiveRes, valueChainRes, keyFactorsRes] =
await Promise.all(requests);
if (comprehensiveRes.success) setComprehensiveData(comprehensiveRes.data);
if (valueChainRes.success) setValueChainData(valueChainRes.data);
if (keyFactorsRes.success) setKeyFactorsData(keyFactorsRes.data);
} catch (err) {
logger.error("DeepAnalysis", "loadDeepAnalysisData", err, { stockCode });
} finally {
setLoading(false);
}
};
// stockCode 变更时重新加载数据
useEffect(() => {
if (stockCode) {
// 重置数据
setComprehensiveData(null);
setValueChainData(null);
setKeyFactorsData(null);
setExpandedSegments({});
// 加载新数据
loadDeepAnalysisData();
}
}, [stockCode]);
return (
<DeepAnalysisTab
comprehensiveData={comprehensiveData}
valueChainData={valueChainData}
keyFactorsData={keyFactorsData}
loading={loading}
cardBg="white"
expandedSegments={expandedSegments}
onToggleSegment={toggleSegmentExpansion}
/>
);
};
export default DeepAnalysis;

View File

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

View File

@@ -1,6 +1,6 @@
// src/views/Company/FinancialPanorama.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import { logger } from '@utils/logger';
import {
Box,
Container,
@@ -35,7 +35,6 @@ import {
VStack,
HStack,
Divider,
useColorModeValue,
Select,
Button,
Tooltip,
@@ -60,7 +59,6 @@ import {
ButtonGroup,
Stack,
Collapse,
useColorMode,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
@@ -75,7 +73,7 @@ import {
ArrowDownIcon,
} from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { financialService, formatUtils, chartUtils } from '../../services/financialService';
import { financialService, formatUtils, chartUtils } from '@services/financialService';
const FinancialPanorama = ({ stockCode: propStockCode }) => {
// 状态管理
@@ -84,7 +82,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(8);
const [activeTab, setActiveTab] = useState(0);
// 财务数据状态
const [stockInfo, setStockInfo] = useState(null);
const [balanceSheet, setBalanceSheet] = useState([]);
@@ -101,14 +99,13 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const [modalContent, setModalContent] = useState(null);
const [expandedRows, setExpandedRows] = useState({});
const toast = useToast();
const { colorMode } = useColorMode();
// 颜色配置(中国市场:红涨绿跌)
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨
const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌
const bgColor = 'white';
const borderColor = 'gray.200';
const hoverBg = 'gray.50';
const positiveColor = 'red.500'; // 红涨
const negativeColor = 'green.500'; // 绿跌
// 加载所有财务数据
const loadFinancialData = async () => {
@@ -492,7 +489,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
<React.Fragment key={section.key}>
{section.title !== '资产总计' && section.title !== '负债合计' && (
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -515,7 +512,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, balanceSheet, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -733,7 +730,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
const renderSection = (section) => (
<React.Fragment key={section.key}>
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
@@ -755,8 +752,8 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, incomeStatement, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') :
metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'}
bg={metric.isTotal ? 'blue.50' :
metric.isSubtotal ? 'orange.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
@@ -1268,7 +1265,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
{ label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' },
{ label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' },
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg={useColorModeValue('gray.50', 'gray.700')}>
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
<Text fontSize="xs" color="gray.500">{item.label}</Text>
<Text fontSize="lg" fontWeight="bold">
{item.format === 'percent' ?

View File

@@ -4,7 +4,7 @@ import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack }
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';
import { stockService } from '@services/eventService';
const ForecastReport = ({ stockCode: propStockCode }) => {
const [code, setCode] = useState(propStockCode || '600000');

View File

@@ -0,0 +1,188 @@
// src/views/Company/components/MarketDataView/components/AnalysisModal.tsx
// 涨幅分析模态框组件
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Box,
Heading,
Text,
Tag,
Badge,
Icon,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import MarkdownRenderer from './MarkdownRenderer';
import { formatNumber } from '../utils/formatUtils';
import type { AnalysisModalProps, RiseAnalysis, Theme } from '../types';
/**
* 涨幅分析内容组件
*/
interface AnalysisContentProps {
analysis: RiseAnalysis;
theme: Theme;
}
export const AnalysisContent: React.FC<AnalysisContentProps> = ({ analysis, theme }) => {
return (
<VStack align="stretch" spacing={4}>
{/* 头部信息 */}
<Box>
<Heading size="md" mb={2}>
{analysis.stock_name} ({analysis.stock_code})
</Heading>
<HStack spacing={4} mb={4}>
<Tag colorScheme="blue">: {analysis.trade_date}</Tag>
<Tag colorScheme="red">: {analysis.rise_rate}%</Tag>
<Tag colorScheme="green">: {analysis.close_price}</Tag>
</HStack>
</Box>
{/* 主营业务 */}
{analysis.main_business && (
<Box p={4} bg="gray.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<Text color={theme.textPrimary}>{analysis.main_business}</Text>
</Box>
)}
{/* 详细分析 */}
{analysis.rise_reason_detail && (
<Box p={4} bg="purple.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<MarkdownRenderer theme={theme}>{analysis.rise_reason_detail}</MarkdownRenderer>
</Box>
)}
{/* 相关公告 */}
{analysis.announcements && analysis.announcements !== '[]' && (
<Box p={4} bg="orange.50" borderRadius="md">
<Heading size="sm" mb={2} color={theme.primary}>
</Heading>
<MarkdownRenderer theme={theme}>{analysis.announcements}</MarkdownRenderer>
</Box>
)}
{/* 研报引用 */}
{analysis.verification_reports && analysis.verification_reports.length > 0 && (
<Box p={4} bg="blue.50" borderRadius="md">
<Heading size="sm" mb={3} color={theme.primary}>
<HStack spacing={2}>
<Icon as={ExternalLinkIcon} />
<Text> ({analysis.verification_reports.length})</Text>
</HStack>
</Heading>
<VStack spacing={3} align="stretch">
{analysis.verification_reports.map((report, reportIdx) => (
<Box
key={reportIdx}
p={3}
bg="white"
borderRadius="md"
border="1px solid"
borderColor={theme.border}
>
<HStack justify="space-between" mb={2}>
<HStack spacing={2}>
<Badge colorScheme="blue" fontSize="xs">
{report.publisher || '未知机构'}
</Badge>
{report.match_score && (
<Badge
colorScheme={
report.match_score === '好'
? 'green'
: report.match_score === '中'
? 'yellow'
: 'gray'
}
fontSize="xs"
>
: {report.match_score}
</Badge>
)}
{report.match_ratio != null && report.match_ratio > 0 && (
<Badge colorScheme="purple" fontSize="xs">
{(report.match_ratio * 100).toFixed(0)}%
</Badge>
)}
</HStack>
{report.declare_date && (
<Text fontSize="xs" color={theme.textMuted}>
{report.declare_date.substring(0, 10)}
</Text>
)}
</HStack>
{report.report_title && (
<Text fontWeight="bold" fontSize="sm" color={theme.textPrimary} mb={1}>
{report.report_title}
</Text>
)}
{report.author && (
<Text fontSize="xs" color={theme.textMuted} mb={2}>
: {report.author}
</Text>
)}
{report.verification_item && (
<Box p={2} bg="yellow.50" borderRadius="sm" mb={2}>
<Text fontSize="xs" color={theme.textMuted}>
<strong>:</strong> {report.verification_item}
</Text>
</Box>
)}
{report.content && (
<Text fontSize="sm" color={theme.textSecondary} noOfLines={4}>
{report.content}
</Text>
)}
</Box>
))}
</VStack>
</Box>
)}
{/* 底部统计 */}
<Box mt={4}>
<Text fontSize="sm" color={theme.textMuted}>
: {formatNumber(analysis.volume)} | : {formatNumber(analysis.amount)} | :{' '}
{analysis.update_time || analysis.create_time || '-'}
</Text>
</Box>
</VStack>
);
};
/**
* 涨幅分析模态框组件
*/
const AnalysisModal: React.FC<AnalysisModalProps> = ({ isOpen, onClose, content, theme }) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent bg={theme.bgCard}>
<ModalHeader color={theme.textPrimary}></ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>{content}</ModalBody>
</ModalContent>
</Modal>
);
};
export default AnalysisModal;

View File

@@ -0,0 +1,65 @@
// src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx
// Markdown 渲染组件
import React from 'react';
import { Box } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import type { MarkdownRendererProps } from '../types';
/**
* Markdown 渲染组件
* 提供统一的 Markdown 样式
*/
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ children, theme }) => {
return (
<Box
color={theme.textPrimary}
sx={{
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: theme.primary,
marginTop: 4,
marginBottom: 2,
fontWeight: 'bold',
},
'& h1': { fontSize: '1.5em' },
'& h2': { fontSize: '1.3em' },
'& h3': { fontSize: '1.1em' },
'& p': {
marginBottom: 3,
lineHeight: 1.6,
},
'& ul, & ol': {
paddingLeft: 4,
marginBottom: 3,
},
'& li': {
marginBottom: 1,
},
'& strong': {
fontWeight: 'bold',
color: theme.textSecondary,
},
'& em': {
fontStyle: 'italic',
},
'& code': {
backgroundColor: 'rgba(0,0,0,0.05)',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '0.9em',
},
'& blockquote': {
borderLeft: `3px solid ${theme.primary}`,
paddingLeft: 4,
marginLeft: 2,
fontStyle: 'italic',
opacity: 0.9,
},
}}
>
<ReactMarkdown>{children}</ReactMarkdown>
</Box>
);
};
export default MarkdownRenderer;

View File

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

View File

@@ -0,0 +1,32 @@
// src/views/Company/components/MarketDataView/components/ThemedCard.tsx
// 主题化卡片组件
import React from 'react';
import { Card } from '@chakra-ui/react';
import type { ThemedCardProps } from '../types';
/**
* 主题化卡片组件
* 提供统一的卡片样式和悬停效果
*/
const ThemedCard: React.FC<ThemedCardProps> = ({ children, theme, ...props }) => {
return (
<Card
bg={theme.bgCard}
border="1px solid"
borderColor={theme.border}
borderRadius="xl"
boxShadow="lg"
transition="all 0.3s ease"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'xl',
}}
{...props}
>
{children}
</Card>
);
};
export default ThemedCard;

View File

@@ -0,0 +1,7 @@
// src/views/Company/components/MarketDataView/components/index.ts
// 组件导出索引
export { default as ThemedCard } from './ThemedCard';
export { default as MarkdownRenderer } from './MarkdownRenderer';
export { default as StockSummaryCard } from './StockSummaryCard';
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';

View File

@@ -0,0 +1,49 @@
// src/views/Company/components/MarketDataView/constants.ts
// MarketDataView 常量配置
import type { Theme } from './types';
/**
* 主题配置
*/
export const themes: Record<'light', Theme> = {
light: {
// 日间模式 - 白+蓝
primary: '#2B6CB0',
primaryDark: '#1E4E8C',
secondary: '#FFFFFF',
secondaryDark: '#F7FAFC',
success: '#FF4444', // 涨 - 红色
danger: '#00C851', // 跌 - 绿色
warning: '#FF9800',
info: '#00BCD4',
bgMain: '#F7FAFC',
bgCard: '#FFFFFF',
bgDark: '#EDF2F7',
textPrimary: '#2D3748',
textSecondary: '#4A5568',
textMuted: '#718096',
border: '#CBD5E0',
chartBg: '#FFFFFF',
},
};
/**
* 默认股票代码
*/
export const DEFAULT_STOCK_CODE = '600000';
/**
* 默认时间周期(天)
*/
export const DEFAULT_PERIOD = 60;
/**
* 时间周期选项
*/
export const PERIOD_OPTIONS = [
{ value: 30, label: '30天' },
{ value: 60, label: '60天' },
{ value: 120, label: '120天' },
{ value: 250, label: '250天' },
];

View File

@@ -0,0 +1,193 @@
// src/views/Company/components/MarketDataView/hooks/useMarketData.ts
// MarketDataView 数据获取 Hook
import { useState, useEffect, useCallback } from 'react';
import { logger } from '@utils/logger';
import { marketService } from '../services/marketService';
import { DEFAULT_PERIOD } from '../constants';
import type {
MarketSummary,
TradeDayData,
FundingDayData,
BigDealData,
UnusualData,
PledgeData,
RiseAnalysis,
MinuteData,
UseMarketDataReturn,
} from '../types';
/**
* 市场数据获取 Hook
* @param stockCode 股票代码
* @param period 时间周期(天数)
*/
export const useMarketData = (
stockCode: string,
period: number = DEFAULT_PERIOD
): UseMarketDataReturn => {
// 主数据状态
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<MarketSummary | null>(null);
const [tradeData, setTradeData] = useState<TradeDayData[]>([]);
const [fundingData, setFundingData] = useState<FundingDayData[]>([]);
const [bigDealData, setBigDealData] = useState<BigDealData>({ data: [], daily_stats: [] });
const [unusualData, setUnusualData] = useState<UnusualData>({ data: [], grouped_data: [] });
const [pledgeData, setPledgeData] = useState<PledgeData[]>([]);
const [analysisMap, setAnalysisMap] = useState<Record<number, RiseAnalysis>>({});
// 分钟数据状态
const [minuteData, setMinuteData] = useState<MinuteData | null>(null);
const [minuteLoading, setMinuteLoading] = useState(false);
/**
* 加载所有市场数据
*/
const loadMarketData = useCallback(async () => {
if (!stockCode) return;
logger.debug('useMarketData', '开始加载市场数据', { stockCode, period });
setLoading(true);
try {
const [
summaryRes,
tradeRes,
fundingRes,
bigDealRes,
unusualRes,
pledgeRes,
riseAnalysisRes,
] = await Promise.all([
marketService.getMarketSummary(stockCode),
marketService.getTradeData(stockCode, period),
marketService.getFundingData(stockCode, 30),
marketService.getBigDealData(stockCode, 30),
marketService.getUnusualData(stockCode, 30),
marketService.getPledgeData(stockCode),
marketService.getRiseAnalysis(stockCode),
]);
// 设置概览数据
if (summaryRes.success) {
setSummary(summaryRes.data);
}
// 设置交易数据
if (tradeRes.success) {
setTradeData(tradeRes.data);
}
// 设置融资融券数据
if (fundingRes.success) {
setFundingData(fundingRes.data);
}
// 设置大宗交易数据(包含 daily_stats
if (bigDealRes.success) {
setBigDealData(bigDealRes);
}
// 设置龙虎榜数据(包含 grouped_data
if (unusualRes.success) {
setUnusualData(unusualRes);
}
// 设置股权质押数据
if (pledgeRes.success) {
setPledgeData(pledgeRes.data);
}
// 设置涨幅分析数据并创建映射
if (riseAnalysisRes.success) {
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
if (tradeRes.success && tradeRes.data && riseAnalysisRes.data) {
riseAnalysisRes.data.forEach((analysis) => {
const dateIndex = tradeRes.data.findIndex(
(item) => item.date.substring(0, 10) === analysis.trade_date
);
if (dateIndex !== -1) {
tempAnalysisMap[dateIndex] = analysis;
}
});
}
setAnalysisMap(tempAnalysisMap);
}
logger.info('useMarketData', '市场数据加载成功', { stockCode });
} catch (error) {
logger.error('useMarketData', 'loadMarketData', error, { stockCode, period });
} finally {
setLoading(false);
}
}, [stockCode, period]);
/**
* 加载分钟K线数据
*/
const loadMinuteData = useCallback(async () => {
if (!stockCode) return;
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
setMinuteLoading(true);
try {
const data = await marketService.getMinuteData(stockCode);
setMinuteData(data);
if (data.data && data.data.length > 0) {
logger.info('useMarketData', '分钟频数据加载成功', {
stockCode,
dataPoints: data.data.length,
});
} else {
logger.warn('useMarketData', '分钟频数据为空', { stockCode });
}
} catch (error) {
logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
setMinuteData({
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
});
} finally {
setMinuteLoading(false);
}
}, [stockCode]);
/**
* 刷新所有数据
*/
const refetch = useCallback(async () => {
await Promise.all([loadMarketData(), loadMinuteData()]);
}, [loadMarketData, loadMinuteData]);
// 监听股票代码和周期变化,自动加载数据
useEffect(() => {
if (stockCode) {
loadMarketData();
loadMinuteData();
}
}, [stockCode, period, loadMarketData, loadMinuteData]);
return {
loading,
summary,
tradeData,
fundingData,
bigDealData,
unusualData,
pledgeData,
minuteData,
minuteLoading,
analysisMap,
refetch,
loadMinuteData,
};
};
export default useMarketData;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
// src/views/Company/components/MarketDataView/services/marketService.ts
// MarketDataView API 服务层
import { getApiBase } from '@utils/apiConfig';
import { logger } from '@utils/logger';
import type {
MarketSummary,
TradeDayData,
FundingDayData,
BigDealData,
UnusualData,
PledgeData,
RiseAnalysis,
MinuteData,
} from '../types';
/**
* API 响应包装类型
*/
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
/**
* API 基础 URL
*/
const getBaseUrl = (): string => getApiBase();
/**
* 通用 API 请求函数
*/
const apiRequest = async <T>(url: string): Promise<ApiResponse<T>> => {
try {
const response = await fetch(`${getBaseUrl()}${url}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
};
/**
* 市场数据服务
*/
export const marketService = {
/**
* 获取市场概览数据
* @param stockCode 股票代码
*/
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
return apiRequest<MarketSummary>(`/api/market/summary/${stockCode}`);
},
/**
* 获取交易日数据
* @param stockCode 股票代码
* @param days 天数,默认 60 天
*/
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
return apiRequest<TradeDayData[]>(`/api/market/trade/${stockCode}?days=${days}`);
},
/**
* 获取融资融券数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
return apiRequest<FundingDayData[]>(`/api/market/funding/${stockCode}?days=${days}`);
},
/**
* 获取大宗交易数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
const response = await fetch(`${getBaseUrl()}/api/market/bigdeal/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
/**
* 获取龙虎榜数据
* @param stockCode 股票代码
* @param days 天数,默认 30 天
*/
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
const response = await fetch(`${getBaseUrl()}/api/market/unusual/${stockCode}?days=${days}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
/**
* 获取股权质押数据
* @param stockCode 股票代码
*/
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
return apiRequest<PledgeData[]>(`/api/market/pledge/${stockCode}`);
},
/**
* 获取涨幅分析数据
* @param stockCode 股票代码
* @param startDate 开始日期(可选)
* @param endDate 结束日期(可选)
*/
async getRiseAnalysis(
stockCode: string,
startDate?: string,
endDate?: string
): Promise<ApiResponse<RiseAnalysis[]>> {
let url = `/api/market/rise-analysis/${stockCode}`;
if (startDate && endDate) {
url += `?start_date=${startDate}&end_date=${endDate}`;
}
return apiRequest<RiseAnalysis[]>(url);
},
/**
* 获取分钟K线数据
* @param stockCode 股票代码
*/
async getMinuteData(stockCode: string): Promise<MinuteData> {
try {
const response = await fetch(`${getBaseUrl()}/api/stock/${stockCode}/latest-minute`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch minute data');
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
return data;
}
// 返回空数据结构
return {
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
};
} catch (error) {
logger.error('marketService', 'getMinuteData', error, { stockCode });
// 返回空数据结构
return {
data: [],
code: stockCode,
name: '',
trade_date: '',
type: 'minute',
};
}
},
};
export default marketService;

View File

@@ -0,0 +1,383 @@
// src/views/Company/components/MarketDataView/types.ts
// MarketDataView 组件类型定义
import type { ReactNode } from 'react';
/**
* 主题配置类型
*/
export interface Theme {
primary: string;
primaryDark: string;
secondary: string;
secondaryDark: string;
success: string; // 涨色 - 红色
danger: string; // 跌色 - 绿色
warning: string;
info: string;
bgMain: string;
bgCard: string;
bgDark: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
border: string;
chartBg: string;
}
/**
* 交易日数据
*/
export interface TradeDayData {
date: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
amount: number;
change_percent: number;
turnover_rate?: number;
pe_ratio?: number;
}
/**
* 分钟K线数据点
*/
export interface MinuteDataPoint {
time: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
amount: number;
}
/**
* 分钟K线数据
*/
export interface MinuteData {
data: MinuteDataPoint[];
code: string;
name: string;
trade_date: string;
type: string;
}
/**
* 融资数据
*/
export interface FinancingInfo {
balance: number;
buy: number;
repay: number;
}
/**
* 融券数据
*/
export interface SecuritiesInfo {
balance: number;
balance_amount: number;
sell: number;
repay: number;
}
/**
* 融资融券日数据
*/
export interface FundingDayData {
date: string;
financing: FinancingInfo;
securities: SecuritiesInfo;
}
/**
* 大宗交易明细
*/
export interface BigDealItem {
buyer_dept?: string;
seller_dept?: string;
price?: number;
volume?: number;
amount?: number;
}
/**
* 大宗交易日统计
*/
export interface BigDealDayStats {
date: string;
count: number;
total_volume: number;
total_amount: number;
avg_price?: number;
deals?: BigDealItem[];
}
/**
* 大宗交易数据
*/
export interface BigDealData {
success?: boolean;
data: BigDealItem[];
daily_stats: BigDealDayStats[];
}
/**
* 龙虎榜买卖方
*/
export interface UnusualTrader {
dept_name: string;
buy_amount?: number;
sell_amount?: number;
}
/**
* 龙虎榜日数据
*/
export interface UnusualDayData {
date: string;
total_buy: number;
total_sell: number;
net_amount: number;
buyers?: UnusualTrader[];
sellers?: UnusualTrader[];
info_types?: string[];
}
/**
* 龙虎榜数据
*/
export interface UnusualData {
success?: boolean;
data: unknown[];
grouped_data: UnusualDayData[];
}
/**
* 股权质押数据
*/
export interface PledgeData {
end_date: string;
unrestricted_pledge: number;
restricted_pledge: number;
total_pledge: number;
total_shares: number;
pledge_ratio: number;
pledge_count: number;
}
/**
* 最新交易数据
*/
export interface LatestTrade {
close: number;
change_percent: number;
volume: number;
amount: number;
turnover_rate: number;
pe_ratio?: number;
}
/**
* 最新融资融券数据
*/
export interface LatestFunding {
financing_balance: number;
securities_balance: number;
}
/**
* 最新质押数据
*/
export interface LatestPledge {
pledge_ratio: number;
}
/**
* 市场概览数据
*/
export interface MarketSummary {
stock_code: string;
stock_name: string;
latest_trade?: LatestTrade;
latest_funding?: LatestFunding;
latest_pledge?: LatestPledge;
}
/**
* 涨幅分析研报
*/
export interface VerificationReport {
publisher?: string;
match_score?: string;
match_ratio?: number;
declare_date?: string;
report_title?: string;
author?: string;
verification_item?: string;
content?: string;
}
/**
* 涨幅分析数据
*/
export interface RiseAnalysis {
stock_code: string;
stock_name: string;
trade_date: string;
rise_rate: number;
close_price: number;
volume: number;
amount: number;
main_business?: string;
rise_reason_brief?: string;
rise_reason_detail?: string;
announcements?: string;
verification_reports?: VerificationReport[];
update_time?: string;
create_time?: string;
}
/**
* MarketDataView 组件 Props
*/
export interface MarketDataViewProps {
stockCode?: string;
}
/**
* ThemedCard 组件 Props
*/
export interface ThemedCardProps {
children: ReactNode;
theme: Theme;
[key: string]: unknown;
}
/**
* MarkdownRenderer 组件 Props
*/
export interface MarkdownRendererProps {
children: string;
theme: Theme;
}
/**
* StockSummaryCard 组件 Props
*/
export interface StockSummaryCardProps {
summary: MarketSummary;
theme: Theme;
}
/**
* TradeDataTab 组件 Props
*/
export interface TradeDataTabProps {
theme: Theme;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
onLoadMinuteData: () => void;
onAnalysisClick: (analysis: RiseAnalysis) => void;
}
/**
* KLineChart 组件 Props
*/
export interface KLineChartProps {
theme: Theme;
tradeData: TradeDayData[];
analysisMap: Record<number, RiseAnalysis>;
onAnalysisClick: (analysis: RiseAnalysis) => void;
}
/**
* MinuteKLineChart 组件 Props
*/
export interface MinuteKLineChartProps {
theme: Theme;
minuteData: MinuteData | null;
loading: boolean;
onRefresh: () => void;
}
/**
* TradeTable 组件 Props
*/
export interface TradeTableProps {
theme: Theme;
tradeData: TradeDayData[];
}
/**
* FundingTab 组件 Props
*/
export interface FundingTabProps {
theme: Theme;
fundingData: FundingDayData[];
}
/**
* BigDealTab 组件 Props
*/
export interface BigDealTabProps {
theme: Theme;
bigDealData: BigDealData;
}
/**
* UnusualTab 组件 Props
*/
export interface UnusualTabProps {
theme: Theme;
unusualData: UnusualData;
}
/**
* PledgeTab 组件 Props
*/
export interface PledgeTabProps {
theme: Theme;
pledgeData: PledgeData[];
}
/**
* AnalysisModal 组件 Props
*/
export interface AnalysisModalProps {
isOpen: boolean;
onClose: () => void;
content: ReactNode;
theme: Theme;
}
/**
* AnalysisModalContent 组件 Props
*/
export interface AnalysisModalContentProps {
analysis: RiseAnalysis;
theme: Theme;
}
/**
* useMarketData Hook 返回值
*/
export interface UseMarketDataReturn {
loading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
fundingData: FundingDayData[];
bigDealData: BigDealData;
unusualData: UnusualData;
pledgeData: PledgeData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
}

View File

@@ -0,0 +1,698 @@
// src/views/Company/components/MarketDataView/utils/chartOptions.ts
// MarketDataView ECharts 图表配置生成器
import type { EChartsOption } from 'echarts';
import type {
Theme,
TradeDayData,
MinuteData,
FundingDayData,
PledgeData,
RiseAnalysis,
} from '../types';
import { formatNumber } from './formatUtils';
/**
* 计算移动平均线
* @param data 收盘价数组
* @param period 周期
*/
export const calculateMA = (data: number[], period: number): (number | null)[] => {
const result: (number | null)[] = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
result.push(null);
continue;
}
let sum = 0;
for (let j = 0; j < period; j++) {
sum += data[i - j];
}
result.push(sum / period);
}
return result;
};
/**
* 生成日K线图配置
*/
export const getKLineOption = (
theme: Theme,
tradeData: TradeDayData[],
analysisMap: Record<number, RiseAnalysis>
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
const dates = tradeData.map((item) => item.date.substring(5, 10));
const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = tradeData.map((item) => item.volume);
const closePrices = tradeData.map((item) => item.close);
const ma5 = calculateMA(closePrices, 5);
const ma10 = calculateMA(closePrices, 10);
const ma20 = calculateMA(closePrices, 20);
// 创建涨幅分析标记点
const scatterData: [number, number][] = [];
Object.keys(analysisMap).forEach((dateIndex) => {
const idx = parseInt(dateIndex);
if (tradeData[idx]) {
const value = tradeData[idx].high * 1.02;
scatterData.push([idx, value]);
}
});
return {
backgroundColor: theme.chartBg,
animation: true,
legend: {
data: ['K线', 'MA5', 'MA10', 'MA20'],
top: 10,
textStyle: {
color: theme.textPrimary,
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
lineStyle: {
color: theme.primary,
width: 1,
opacity: 0.8,
},
},
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: false,
axisLine: { onZero: false, lineStyle: { color: theme.textMuted } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
},
],
yAxis: [
{
scale: true,
splitLine: {
show: true,
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
],
grid: [
{
left: '10%',
right: '10%',
height: '50%',
},
{
left: '10%',
right: '10%',
top: '65%',
height: '20%',
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
lineStyle: {
color: theme.primary,
width: 1,
},
itemStyle: {
color: theme.primary,
},
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
lineStyle: {
color: theme.info,
width: 1,
},
itemStyle: {
color: theme.info,
},
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
lineStyle: {
color: theme.warning,
width: 1,
},
itemStyle: {
color: theme.warning,
},
},
{
name: '涨幅分析',
type: 'scatter',
data: scatterData,
symbolSize: 30,
symbol: 'pin',
itemStyle: {
color: '#FFD700',
shadowBlur: 10,
shadowColor: 'rgba(255, 215, 0, 0.5)',
},
label: {
show: true,
formatter: '★',
fontSize: 20,
position: 'inside',
color: '#FF6B6B',
},
emphasis: {
scale: 1.5,
itemStyle: {
color: '#FFA500',
},
},
z: 100,
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = tradeData[params.dataIndex];
return item.change_percent >= 0
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成分钟K线图配置
*/
export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null): EChartsOption => {
if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {};
const times = minuteData.data.map((item) => item.time);
const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = minuteData.data.map((item) => item.volume);
const closePrices = minuteData.data.map((item) => item.close);
const avgPrice = calculateMA(closePrices, 5);
const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0;
return {
backgroundColor: theme.chartBg,
title: {
text: `${minuteData.name} 分钟K线 (${minuteData.trade_date})`,
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
fontWeight: 'bold',
},
subtextStyle: {
color: theme.textMuted,
},
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
if (param.seriesName === '分钟K线') {
const [open, close, , high] = param.data as number[];
const low = (param.data as number[])[2];
const changePercent =
openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00';
result += `${param.marker} ${param.seriesName}<br/>`;
result += `开盘: <span style="font-weight: bold">${open.toFixed(2)}</span><br/>`;
result += `收盘: <span style="font-weight: bold; color: ${close >= open ? theme.success : theme.danger}">${close.toFixed(2)}</span><br/>`;
result += `最高: <span style="font-weight: bold">${high.toFixed(2)}</span><br/>`;
result += `最低: <span style="font-weight: bold">${low.toFixed(2)}</span><br/>`;
result += `涨跌: <span style="font-weight: bold; color: ${close >= openPrice ? theme.success : theme.danger}">${changePercent}%</span><br/>`;
} else if (param.seriesName === '均价线') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${(param.value as number).toFixed(2)}</span><br/>`;
} else if (param.seriesName === '成交量') {
result += `${param.marker} ${param.seriesName}: <span style="font-weight: bold">${formatNumber(param.value as number, 0)}</span><br/>`;
}
});
return result;
},
},
legend: {
data: ['分钟K线', '均价线', '成交量'],
top: 35,
textStyle: {
color: theme.textPrimary,
fontSize: 12,
},
itemWidth: 25,
itemHeight: 14,
},
grid: [
{
left: '8%',
right: '8%',
top: '20%',
height: '60%',
},
{
left: '8%',
right: '8%',
top: '83%',
height: '12%',
},
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
interval: 'auto',
},
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: times,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: {
color: theme.textMuted,
fontSize: 10,
},
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: {
lineStyle: {
color: theme.border,
type: 'dashed',
},
},
},
{
gridIndex: 1,
scale: true,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted, fontSize: 10 },
splitLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: 70,
end: 100,
minValueSpan: 20,
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
top: '95%',
start: 70,
end: 100,
height: 20,
handleSize: '100%',
handleStyle: {
color: theme.primary,
},
textStyle: {
color: theme.textMuted,
},
},
],
series: [
{
name: '分钟K线',
type: 'candlestick',
data: kData,
itemStyle: {
color: theme.success,
color0: theme.danger,
borderColor: theme.success,
borderColor0: theme.danger,
borderWidth: 1,
},
barWidth: '60%',
},
{
name: '均价线',
type: 'line',
data: avgPrice,
smooth: true,
symbol: 'none',
lineStyle: {
color: theme.info,
width: 2,
opacity: 0.8,
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
barWidth: '50%',
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = minuteData.data[params.dataIndex];
return item.close >= item.open
? 'rgba(255, 68, 68, 0.6)'
: 'rgba(0, 200, 81, 0.6)';
},
},
},
],
};
};
/**
* 生成融资融券图表配置
*/
export const getFundingOption = (theme: Theme, fundingData: FundingDayData[]): EChartsOption => {
if (!fundingData || fundingData.length === 0) return {};
const dates = fundingData.map((item) => item.date.substring(5, 10));
const financing = fundingData.map((item) => item.financing.balance / 100000000);
const securities = fundingData.map((item) => item.securities.balance_amount / 100000000);
return {
backgroundColor: theme.chartBg,
title: {
text: '融资融券余额走势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
formatter: (params: unknown) => {
const paramsArr = params as { name: string; marker: string; seriesName: string; value: number }[];
let result = paramsArr[0].name + '<br/>';
paramsArr.forEach((param) => {
result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿<br/>`;
});
return result;
},
},
legend: {
data: ['融资余额', '融券余额'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: {
type: 'value',
name: '金额(亿)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
series: [
{
name: '融资余额',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 68, 68, 0.3)' },
{ offset: 1, color: 'rgba(255, 68, 68, 0.05)' },
],
},
},
lineStyle: {
color: theme.success,
width: 2,
},
itemStyle: {
color: theme.success,
borderColor: theme.success,
borderWidth: 2,
},
data: financing,
},
{
name: '融券余额',
type: 'line',
smooth: true,
symbol: 'diamond',
symbolSize: 8,
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 200, 81, 0.3)' },
{ offset: 1, color: 'rgba(0, 200, 81, 0.05)' },
],
},
},
lineStyle: {
color: theme.danger,
width: 2,
},
itemStyle: {
color: theme.danger,
borderColor: theme.danger,
borderWidth: 2,
},
data: securities,
},
],
};
};
/**
* 生成股权质押图表配置
*/
export const getPledgeOption = (theme: Theme, pledgeData: PledgeData[]): EChartsOption => {
if (!pledgeData || pledgeData.length === 0) return {};
const dates = pledgeData.map((item) => item.end_date.substring(5, 10));
const ratios = pledgeData.map((item) => item.pledge_ratio);
const counts = pledgeData.map((item) => item.pledge_count);
return {
backgroundColor: theme.chartBg,
title: {
text: '股权质押趋势',
left: 'center',
textStyle: {
color: theme.textPrimary,
fontSize: 16,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: theme.primary,
borderWidth: 1,
textStyle: {
color: theme.textPrimary,
},
},
legend: {
data: ['质押比例', '质押笔数'],
bottom: 10,
textStyle: {
color: theme.textPrimary,
},
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
yAxis: [
{
type: 'value',
name: '质押比例(%)',
nameTextStyle: { color: theme.textMuted },
splitLine: {
lineStyle: {
color: theme.border,
},
},
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
{
type: 'value',
name: '质押笔数',
nameTextStyle: { color: theme.textMuted },
axisLine: { lineStyle: { color: theme.textMuted } },
axisLabel: { color: theme.textMuted },
},
],
series: [
{
name: '质押比例',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: theme.warning,
width: 2,
shadowBlur: 10,
shadowColor: theme.warning,
},
itemStyle: {
color: theme.warning,
borderColor: theme.bgCard,
borderWidth: 2,
},
data: ratios,
},
{
name: '质押笔数',
type: 'bar',
yAxisIndex: 1,
barWidth: '50%',
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: theme.primary },
{ offset: 1, color: theme.primaryDark },
],
},
borderRadius: [5, 5, 0, 0],
},
data: counts,
},
],
};
};
export default {
calculateMA,
getKLineOption,
getMinuteKLineOption,
getFundingOption,
getPledgeOption,
};

View File

@@ -0,0 +1,175 @@
// src/views/Company/components/MarketDataView/utils/formatUtils.ts
// MarketDataView 格式化工具函数
/**
* 格式化数字(自动转换为万/亿)
* @param value 数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatNumber = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(decimals) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(decimals) + '万';
}
return num.toFixed(decimals);
};
/**
* 格式化百分比
* @param value 数值(已经是百分比形式,如 3.5 表示 3.5%
* @param decimals 小数位数,默认 2
* @returns 格式化后的字符串
*/
export const formatPercent = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals) + '%';
};
/**
* 格式化日期(取前 10 位)
* @param dateStr 日期字符串
* @returns 格式化后的日期YYYY-MM-DD
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(0, 10);
};
/**
* 格式化价格
* @param value 价格数值
* @param decimals 小数位数,默认 2
* @returns 格式化后的价格字符串
*/
export const formatPrice = (value: number | null | undefined, decimals: number = 2): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
return num.toFixed(decimals);
};
/**
* 格式化成交量(带单位)
* @param value 成交量数值
* @returns 格式化后的成交量字符串
*/
export const formatVolume = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (num >= 100000000) {
return (num / 100000000).toFixed(2) + '亿股';
} else if (num >= 10000) {
return (num / 10000).toFixed(2) + '万股';
}
return num.toFixed(0) + '股';
};
/**
* 格式化金额(带单位)
* @param value 金额数值
* @returns 格式化后的金额字符串
*/
export const formatAmount = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
if (Math.abs(num) >= 100000000) {
return (num / 100000000).toFixed(2) + '亿';
} else if (Math.abs(num) >= 10000) {
return (num / 10000).toFixed(2) + '万';
}
return num.toFixed(2) + '元';
};
/**
* 格式化涨跌幅(带符号和颜色提示)
* @param value 涨跌幅数值
* @returns 带符号的涨跌幅字符串
*/
export const formatChange = (value: number | null | undefined): string => {
if (value === null || value === undefined) return '-';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num)) return '-';
const sign = num > 0 ? '+' : '';
return sign + num.toFixed(2) + '%';
};
/**
* 获取涨跌颜色类型
* @param value 涨跌幅数值
* @returns 'up' | 'down' | 'neutral'
*/
export const getChangeType = (value: number | null | undefined): 'up' | 'down' | 'neutral' => {
if (value === null || value === undefined) return 'neutral';
const num = typeof value === 'number' ? value : parseFloat(String(value));
if (isNaN(num) || num === 0) return 'neutral';
return num > 0 ? 'up' : 'down';
};
/**
* 格式化短日期MM-DD
* @param dateStr 日期字符串
* @returns 格式化后的短日期
*/
export const formatShortDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '-';
return dateStr.substring(5, 10);
};
/**
* 格式化时间HH:mm
* @param timeStr 时间字符串
* @returns 格式化后的时间
*/
export const formatTime = (timeStr: string | null | undefined): string => {
if (!timeStr) return '-';
// 支持多种格式
if (timeStr.includes(':')) {
return timeStr.substring(0, 5);
}
// 如果是 HHmm 格式
if (timeStr.length >= 4) {
return timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4);
}
return timeStr;
};
/**
* 工具函数集合(兼容旧代码)
*/
export const formatUtils = {
formatNumber,
formatPercent,
formatDate,
formatPrice,
formatVolume,
formatAmount,
formatChange,
getChangeType,
formatShortDate,
formatTime,
};
export default formatUtils;

View File

@@ -0,0 +1,367 @@
/**
* StockQuoteCard - 股票行情卡片组件
*
* 展示股票的实时行情、关键指标和主力动态
*/
import React from 'react';
import {
Box,
Card,
CardBody,
Flex,
HStack,
VStack,
Text,
Badge,
Progress,
Skeleton,
IconButton,
Tooltip,
Divider,
Link,
Icon,
} from '@chakra-ui/react';
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
import FavoriteButton from '@components/FavoriteButton';
import type { StockQuoteCardProps } from './types';
/**
* 格式化价格显示
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data,
isLoading = false,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
onShare,
basicInfo,
}) => {
// 处理分享点击
const handleShare = () => {
onShare?.();
};
// 黑金主题颜色配置
const cardBg = '#1A202C';
const borderColor = '#C9A961';
const labelColor = '#C9A961';
const valueColor = '#F4D03F';
const sectionTitleColor = '#F4D03F';
// 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色
// 加载中或无数据时显示骨架屏
if (isLoading || !data) {
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
<VStack spacing={4} align="stretch">
<Skeleton height="30px" width="200px" />
<Skeleton height="60px" />
<Skeleton height="80px" />
</VStack>
</CardBody>
</Card>
);
}
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
<HStack spacing={3} align="center">
{/* 股票名称 - 突出显示 */}
<Text fontSize="26px" fontWeight="800" color={valueColor}>
{data.name}
</Text>
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
({data.code})
</Text>
{/* 行业标签 */}
{(data.industryL1 || data.industry) && (
<Badge
bg="transparent"
color={labelColor}
fontSize="14px"
fontWeight="medium"
border="1px solid"
borderColor={borderColor}
px={2}
py={0.5}
borderRadius="md"
>
{data.industryL1 && data.industry
? `${data.industryL1} · ${data.industry}`
: data.industry || data.industryL1}
</Badge>
)}
{/* 指数标签 */}
{data.indexTags?.length > 0 && (
<Text fontSize="14px" color={labelColor}>
{data.indexTags.join('、')}
</Text>
)}
</HStack>
{/* 右侧:关注 + 分享 + 时间 */}
<HStack spacing={3}>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
onClick={onWatchlistToggle || (() => {})}
colorScheme="gold"
size="sm"
/>
<Tooltip label="分享" placement="top">
<IconButton
aria-label="分享"
icon={<Share2 size={18} />}
variant="ghost"
color={labelColor}
size="sm"
onClick={handleShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{data.updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>
{/* 1:2 布局 */}
<Flex gap={8}>
{/* 左栏:价格信息 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(data.currentPrice)}
</Text>
<Badge
bg={data.changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(data.changePercent)}
</Badge>
</HStack>
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(data.todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(data.todayLow)}
</Text>
</Text>
</HStack>
</Box>
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
{/* 关键指标 */}
<Box flex="1">
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}>(PE)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
</Text>
</HStack>
</VStack>
</Box>
{/* 主力动态 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(data.mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={data.buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{data.buyRatio}%</Text>
<Text color={downColor}>{data.sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
</Flex>
</Flex>
{/* 公司信息区块 - 1:2 布局 */}
{basicInfo && (
<>
<Divider borderColor={borderColor} my={4} />
<Flex gap={8}>
{/* 左侧:公司关键属性 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
<HStack spacing={1}>
<Icon as={Calendar} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatDate(basicInfo.establish_date)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Coins} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{formatRegisteredCapital(basicInfo.reg_capital)}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={MapPin} color={labelColor} boxSize={4} />
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold">
{basicInfo.province} {basicInfo.city}
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Globe} color={labelColor} boxSize={4} />
{basicInfo.website ? (
<Link
href={basicInfo.website}
isExternal
color={valueColor}
fontWeight="bold"
_hover={{ color: labelColor }}
>
访
</Link>
) : (
<Text color={valueColor}></Text>
)}
</HStack>
</HStack>
</Box>
{/* 右侧:公司简介 (flex=2) */}
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text fontSize="14px" color={labelColor} noOfLines={2}>
<Text as="span" fontWeight="bold" color={valueColor}></Text>
{basicInfo.company_intro || '暂无'}
</Text>
</Box>
</Flex>
</>
)}
</CardBody>
</Card>
);
};
export default StockQuoteCard;

View File

@@ -0,0 +1,38 @@
import type { StockQuoteCardData } from './types';
/**
* 贵州茅台 Mock 数据
*/
export const mockStockQuoteData: StockQuoteCardData = {
// 基础信息
name: '贵州茅台',
code: '600519.SH',
indexTags: ['沪深300'],
// 价格信息
currentPrice: 2178.5,
changePercent: 3.65,
todayOpen: 2156.0,
yesterdayClose: 2101.0,
todayHigh: 2185.0,
todayLow: 2150.0,
// 关键指标
pe: 38.62,
pb: 14.82,
marketCap: '2.73万亿',
week52Low: 1980,
week52High: 2350,
// 主力动态
mainNetInflow: 1.28,
institutionHolding: 72.35,
buyRatio: 85,
sellRatio: 15,
// 更新时间
updateTime: '2025-12-03 14:30:25',
// 自选状态
isFavorite: false,
};

View File

@@ -0,0 +1,60 @@
/**
* StockQuoteCard 组件类型定义
*/
import type { BasicInfo } from '../CompanyOverview/types';
/**
* 股票行情卡片数据
*/
export interface StockQuoteCardData {
// 基础信息
name: string; // 股票名称
code: string; // 股票代码
indexTags: string[]; // 指数标签(如 沪深300、上证50
industry?: string; // 所属行业(二级),如 "银行"
industryL1?: string; // 一级行业,如 "金融"
// 价格信息
currentPrice: number; // 当前价格
changePercent: number; // 涨跌幅(百分比,如 3.65 表示 +3.65%
todayOpen: number; // 今开
yesterdayClose: number; // 昨收
todayHigh: number; // 今日最高
todayLow: number; // 今日最低
// 关键指标
pe: number; // 市盈率
pb: number; // 市净率
marketCap: string; // 流通市值(已格式化,如 "2.73万亿"
week52Low: number; // 52周最低
week52High: number; // 52周最高
// 主力动态
mainNetInflow: number; // 主力净流入(亿)
institutionHolding: number; // 机构持仓比例(百分比)
buyRatio: number; // 买入比例(百分比)
sellRatio: number; // 卖出比例(百分比)
// 更新时间
updateTime: string; // 格式YYYY-MM-DD HH:mm:ss
// 自选状态
isFavorite?: boolean; // 是否已加入自选
}
/**
* StockQuoteCard 组件 Props
*/
export interface StockQuoteCardProps {
data?: StockQuoteCardData;
isLoading?: boolean;
// 自选股相关(与 WatchlistButton 接口保持一致)
isInWatchlist?: boolean; // 是否在自选股中
isWatchlistLoading?: boolean; // 自选股操作加载中
onWatchlistToggle?: () => void; // 自选股切换回调
// 分享
onShare?: () => void; // 分享回调
// 公司基本信息
basicInfo?: BasicInfo;
}

View File

@@ -0,0 +1,55 @@
// src/views/Company/constants/index.js
// 公司详情页面常量配置
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle, FaBrain, FaNewspaper } from 'react-icons/fa';
/**
* Tab 配置
* @type {Array<{key: string, name: string, icon: React.ComponentType}>}
*/
export const COMPANY_TABS = [
{ key: 'overview', name: '公司档案', icon: FaInfoCircle },
{ key: 'analysis', name: '深度分析', icon: FaBrain },
{ key: 'market', name: '股票行情', icon: FaChartLine },
{ key: 'financial', name: '财务全景', icon: FaMoneyBillWave },
{ key: 'forecast', name: '盈利预测', icon: FaChartBar },
{ key: 'tracking', name: '动态跟踪', icon: FaNewspaper },
];
/**
* Tab 选中状态样式
*/
export const TAB_SELECTED_STYLE = {
transform: 'scale(1.02)',
transition: 'all 0.2s',
};
/**
* Toast 消息配置
*/
export const TOAST_MESSAGES = {
WATCHLIST_ADD: { title: '已加入自选', status: 'success', duration: 1500 },
WATCHLIST_REMOVE: { title: '已从自选移除', status: 'info', duration: 1500 },
WATCHLIST_ERROR: { title: '操作失败,请稍后重试', status: 'error', duration: 2000 },
INVALID_CODE: { title: '无效的股票代码', status: 'error', duration: 2000 },
LOGIN_REQUIRED: { title: '请先登录后再加入自选', status: 'warning', duration: 2000 },
};
/**
* 默认股票代码
*/
export const DEFAULT_STOCK_CODE = '000001';
/**
* URL 参数名
*/
export const URL_PARAM_NAME = 'scode';
/**
* 根据索引获取 Tab 名称
* @param {number} index - Tab 索引
* @returns {string} Tab 名称
*/
export const getTabNameByIndex = (index) => {
return COMPANY_TABS[index]?.name || 'Unknown';
};

View File

@@ -0,0 +1,91 @@
// src/views/Company/hooks/useCompanyStock.js
// 股票代码管理 Hook - 处理 URL 参数同步和搜索逻辑
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DEFAULT_STOCK_CODE, URL_PARAM_NAME } from '../constants';
/**
* 股票代码管理 Hook
*
* 功能:
* - 管理当前股票代码状态
* - 双向同步 URL 参数
* - 处理搜索输入和提交
*
* @param {Object} options - 配置选项
* @param {string} [options.defaultCode] - 默认股票代码
* @param {string} [options.paramName] - URL 参数名
* @param {Function} [options.onStockChange] - 股票代码变化回调 (newCode, prevCode) => void
* @returns {Object} 股票代码状态和操作方法
*/
export const useCompanyStock = (options = {}) => {
const {
defaultCode = DEFAULT_STOCK_CODE,
paramName = URL_PARAM_NAME,
onStockChange,
} = options;
const [searchParams, setSearchParams] = useSearchParams();
// 从 URL 参数初始化股票代码
const [stockCode, setStockCode] = useState(
searchParams.get(paramName) || defaultCode
);
// 输入框状态(默认为空,不显示默认股票代码)
const [inputCode, setInputCode] = useState('');
/**
* 监听 URL 参数变化,同步到本地状态
* 支持浏览器前进/后退按钮
*/
useEffect(() => {
const urlCode = searchParams.get(paramName);
if (urlCode && urlCode !== stockCode) {
setStockCode(urlCode);
setInputCode(urlCode);
}
}, [searchParams, paramName, stockCode]);
/**
* 执行搜索 - 更新 stockCode 和 URL
* @param {string} [code] - 可选,直接传入股票代码(用于下拉选择)
*/
const handleSearch = useCallback((code) => {
const trimmedCode = code || inputCode?.trim();
if (trimmedCode && trimmedCode !== stockCode) {
// 触发变化回调(用于追踪)
onStockChange?.(trimmedCode, stockCode);
// 更新状态
setStockCode(trimmedCode);
// 更新 URL 参数
setSearchParams({ [paramName]: trimmedCode });
}
}, [inputCode, stockCode, paramName, setSearchParams, onStockChange]);
/**
* 处理键盘事件 - 回车键触发搜索
*/
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
handleSearch();
}
}, [handleSearch]);
return {
// 状态
stockCode, // 当前确认的股票代码
inputCode, // 输入框中的值(未确认)
// 操作方法
setInputCode, // 更新输入框
handleSearch, // 执行搜索
handleKeyDown, // 处理回车键(改用 onKeyDown
};
};
export default useCompanyStock;

View File

@@ -0,0 +1,166 @@
// src/views/Company/hooks/useCompanyWatchlist.js
// 自选股管理 Hook - Company 页面专用,复用 Redux stockSlice
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { useAuth } from '@contexts/AuthContext';
import { logger } from '@utils/logger';
import {
loadWatchlist,
toggleWatchlist,
optimisticAddWatchlist,
optimisticRemoveWatchlist
} from '@store/slices/stockSlice';
import { TOAST_MESSAGES } from '../constants';
/**
* Company 页面自选股管理 Hook
*
* 功能:
* - 检查当前股票是否在自选股中
* - 提供添加/移除自选股功能
* - 与 Redux stockSlice 同步
*
* @param {Object} options - 配置选项
* @param {string} options.stockCode - 当前股票代码
* @param {Object} [options.tracking] - 追踪回调
* @param {Function} [options.tracking.onAdd] - 添加自选时的追踪回调
* @param {Function} [options.tracking.onRemove] - 移除自选时的追踪回调
* @returns {Object} 自选股状态和操作方法
*/
export const useCompanyWatchlist = ({ stockCode, tracking = {} } = {}) => {
const dispatch = useDispatch();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 从 Redux 获取自选股列表
const watchlist = useSelector((state) => state.stock.watchlist);
const watchlistLoading = useSelector((state) => state.stock.loading.watchlist);
// 追踪是否已初始化(防止无限循环)
const hasInitializedRef = useRef(false);
/**
* 派生状态:判断当前股票是否在自选股中
* 使用 useMemo 避免重复计算
*/
const isInWatchlist = useMemo(() => {
if (!stockCode || !Array.isArray(watchlist)) {
return false;
}
// 标准化股票代码提取6位数字
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
return watchlist.some((item) => normalize(item.stock_code) === targetCode);
}, [watchlist, stockCode]);
/**
* 初始化:加载自选股列表
* 使用 hasInitializedRef 防止无限循环(用户可能确实没有自选股)
*/
useEffect(() => {
if (!hasInitializedRef.current && isAuthenticated && !watchlistLoading) {
hasInitializedRef.current = true;
dispatch(loadWatchlist());
}
}, [isAuthenticated, watchlistLoading, dispatch]);
/**
* 切换自选股状态(乐观更新模式)
* 1. 立即更新 UI无 loading
* 2. 后台静默请求 API
* 3. 失败时回滚并提示
*/
const toggle = useCallback(async () => {
// 参数校验
if (!stockCode) {
logger.warn('useCompanyWatchlist', 'toggle', '无效的股票代码', { stockCode });
toast(TOAST_MESSAGES.INVALID_CODE);
return;
}
// 权限校验
if (!isAuthenticated) {
logger.warn('useCompanyWatchlist', 'toggle', '用户未登录', { stockCode });
toast(TOAST_MESSAGES.LOGIN_REQUIRED);
return;
}
// 标准化股票代码用于匹配
const normalize = (code) => String(code || '').match(/(\d{6})/)?.[1] || '';
const targetCode = normalize(stockCode);
// 从 watchlist 中找到原始 stock_code保持与后端数据结构一致
const matchedItem = watchlist.find(
item => normalize(item.stock_code) === targetCode
);
// 移除时使用原始 stock_code添加时使用传入的 stockCode
const codeForApi = isInWatchlist ? (matchedItem?.stock_code || stockCode) : stockCode;
// 保存当前状态用于回滚
const wasInWatchlist = isInWatchlist;
logger.debug('useCompanyWatchlist', '切换自选股(乐观更新)', {
stockCode,
codeForApi,
wasInWatchlist,
action: wasInWatchlist ? 'remove' : 'add',
});
// 1. 乐观更新:立即更新 UI不显示 loading
if (wasInWatchlist) {
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
} else {
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
}
try {
// 2. 后台静默请求 API
await dispatch(
toggleWatchlist({
stockCode: codeForApi,
stockName: matchedItem?.stock_name || '',
isInWatchlist: wasInWatchlist,
})
).unwrap();
// 3. 成功:触发追踪回调(不显示 toast状态已更新
if (wasInWatchlist) {
tracking.onRemove?.(stockCode);
} else {
tracking.onAdd?.(stockCode);
}
} catch (error) {
// 4. 失败:回滚状态 + 显示错误提示
logger.error('useCompanyWatchlist', 'toggle', error, {
stockCode,
wasInWatchlist,
});
// 回滚操作
if (wasInWatchlist) {
// 之前在自选中,乐观删除了,现在要恢复
dispatch(optimisticAddWatchlist({ stockCode: codeForApi, stockName: matchedItem?.stock_name || '' }));
} else {
// 之前不在自选中,乐观添加了,现在要移除
dispatch(optimisticRemoveWatchlist({ stockCode: codeForApi }));
}
toast(TOAST_MESSAGES.WATCHLIST_ERROR);
}
}, [stockCode, isAuthenticated, isInWatchlist, watchlist, dispatch, toast, tracking]);
return {
// 状态
isInWatchlist, // 是否在自选股中
isLoading: watchlistLoading, // 仅初始加载时显示 loading乐观更新模式
// 操作方法
toggle, // 切换自选状态
};
};
export default useCompanyWatchlist;

View File

@@ -0,0 +1,102 @@
// src/views/Company/hooks/useStockQuote.js
// 股票行情数据获取 Hook
import { useState, useEffect } from 'react';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 将 API 响应数据转换为 StockQuoteCard 所需格式
*/
const transformQuoteData = (apiData, stockCode) => {
if (!apiData) return null;
return {
// 基础信息
name: apiData.name || apiData.stock_name || '未知',
code: apiData.code || apiData.stock_code || stockCode,
indexTags: apiData.index_tags || apiData.indexTags || [],
industry: apiData.industry || apiData.sw_industry_l2 || '',
industryL1: apiData.industry_l1 || apiData.sw_industry_l1 || '',
// 价格信息
currentPrice: apiData.current_price || apiData.currentPrice || apiData.close || 0,
changePercent: apiData.change_percent || apiData.changePercent || apiData.pct_chg || 0,
todayOpen: apiData.today_open || apiData.todayOpen || apiData.open || 0,
yesterdayClose: apiData.yesterday_close || apiData.yesterdayClose || apiData.pre_close || 0,
todayHigh: apiData.today_high || apiData.todayHigh || apiData.high || 0,
todayLow: apiData.today_low || apiData.todayLow || apiData.low || 0,
// 关键指标
pe: apiData.pe || apiData.pe_ttm || 0,
pb: apiData.pb || apiData.pb_mrq || 0,
marketCap: apiData.market_cap || apiData.marketCap || apiData.circ_mv || '0',
week52Low: apiData.week52_low || apiData.week52Low || 0,
week52High: apiData.week52_high || apiData.week52High || 0,
// 主力动态
mainNetInflow: apiData.main_net_inflow || apiData.mainNetInflow || 0,
institutionHolding: apiData.institution_holding || apiData.institutionHolding || 0,
buyRatio: apiData.buy_ratio || apiData.buyRatio || 50,
sellRatio: apiData.sell_ratio || apiData.sellRatio || 50,
// 更新时间
updateTime: apiData.update_time || apiData.updateTime || new Date().toLocaleString(),
};
};
/**
* 股票行情数据获取 Hook
*
* @param {string} stockCode - 股票代码
* @returns {Object} { data, isLoading, error, refetch }
*/
export const useStockQuote = (stockCode) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!stockCode) {
setData(null);
return;
}
const fetchQuote = async () => {
setIsLoading(true);
setError(null);
try {
logger.debug('useStockQuote', '获取股票行情', { stockCode });
const quotes = await stockService.getQuotes([stockCode]);
// API 返回格式: { [stockCode]: quoteData }
const quoteData = quotes?.[stockCode] || quotes;
const transformedData = transformQuoteData(quoteData, stockCode);
logger.debug('useStockQuote', '行情数据转换完成', { stockCode, hasData: !!transformedData });
setData(transformedData);
} catch (err) {
logger.error('useStockQuote', '获取行情失败', err);
setError(err);
setData(null);
} finally {
setIsLoading(false);
}
};
fetchQuote();
}, [stockCode]);
// 手动刷新
const refetch = () => {
if (stockCode) {
setData(null);
// 触发 useEffect 重新执行
}
};
return { data, isLoading, error, refetch };
};
export default useStockQuote;

View File

@@ -1,64 +1,56 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
// src/views/Company/index.js
// 公司详情页面入口 - 纯组合层
import React, { useEffect, useRef } from 'react';
import { Container, VStack } from '@chakra-ui/react';
import { useDispatch } from 'react-redux';
import { loadAllStocks } from '@store/slices/stockSlice';
import { AutoComplete } from 'antd';
import { stockService } from '@services/stockService';
import {
Container,
Heading,
Card,
CardBody,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
HStack,
VStack,
Button,
Text,
Badge,
Divider,
Icon,
useColorModeValue,
useColorMode,
IconButton,
useToast,
} from '@chakra-ui/react';
import { SearchIcon, MoonIcon, SunIcon, StarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaMoneyBillWave, FaChartBar, FaInfoCircle } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import FinancialPanorama from './FinancialPanorama';
import ForecastReport from './ForecastReport';
import MarketDataView from './MarketDataView';
import CompanyOverview from './CompanyOverview';
// 导入 PostHog 追踪 Hook
// 自定义 Hooks
import { useCompanyStock } from './hooks/useCompanyStock';
import { useCompanyWatchlist } from './hooks/useCompanyWatchlist';
import { useCompanyEvents } from './hooks/useCompanyEvents';
import { useStockQuote } from './hooks/useStockQuote';
import { useBasicInfo } from './components/CompanyOverview/hooks/useBasicInfo';
// 页面组件
import CompanyHeader from './components/CompanyHeader';
import StockQuoteCard from './components/StockQuoteCard';
import CompanyTabs from './components/CompanyTabs';
/**
* 公司详情页面
*
* 功能:
* - 股票搜索与代码管理
* - 自选股添加/移除
* - 多维度数据展示(概览、行情、财务、预测)
* - PostHog 事件追踪
*/
const CompanyIndex = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [stockCode, setStockCode] = useState(searchParams.get('scode') || '000001');
const [inputCode, setInputCode] = useState(stockCode);
const [stockOptions, setStockOptions] = useState([]);
const { colorMode, toggleColorMode } = useColorMode();
const toast = useToast();
const { isAuthenticated } = useAuth();
// 从 Redux 获取股票列表数据
const dispatch = useDispatch();
const allStocks = useSelector((state) => state.stock.allStocks);
// 确保股票数据已加载
// 1. 先获取股票代码(不带追踪回调)
const {
stockCode,
inputCode,
setInputCode,
handleSearch,
handleKeyDown,
} = useCompanyStock();
// 加载全部股票列表(用于模糊搜索)
useEffect(() => {
if (!allStocks || allStocks.length === 0) {
dispatch(loadAllStocks());
}
}, [dispatch, allStocks]);
dispatch(loadAllStocks());
}, [dispatch]);
// 🎯 PostHog 事件追踪
// 2. 获取股票行情数据
const { data: quoteData, isLoading: isQuoteLoading } = useStockQuote(stockCode);
// 2.1 获取公司基本信息
const { basicInfo } = useBasicInfo(stockCode);
// 3. 再初始化事件追踪(传入 stockCode
const {
trackStockSearched,
trackTabChanged,
@@ -66,321 +58,55 @@ const CompanyIndex = () => {
trackWatchlistRemoved,
} = useCompanyEvents({ stockCode });
// Tab 索引状态(用于追踪 Tab 切换)
const [currentTabIndex, setCurrentTabIndex] = useState(0);
// 3. 自选股管理
const {
isInWatchlist,
isLoading: isWatchlistLoading,
toggle: handleWatchlistToggle,
} = useCompanyWatchlist({
stockCode,
tracking: {
onAdd: trackWatchlistAdded,
onRemove: trackWatchlistRemoved,
},
});
const bgColor = useColorModeValue('white', 'gray.800');
const tabBg = useColorModeValue('gray.50', 'gray.700');
const activeBg = useColorModeValue('blue.500', 'blue.400');
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatchlistLoading, setIsWatchlistLoading] = useState(false);
const loadWatchlistStatus = useCallback(async () => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (!resp.ok) {
setIsInWatchlist(false);
return;
}
const data = await resp.json();
const list = Array.isArray(data?.data) ? data.data : [];
const codes = new Set(list.map((item) => item.stock_code));
setIsInWatchlist(codes.has(stockCode));
} catch (e) {
setIsInWatchlist(false);
}
}, [stockCode]);
// 当URL参数变化时更新股票代码
// 4. 监听 stockCode 变化,触发搜索追踪
const prevStockCodeRef = useRef(stockCode);
useEffect(() => {
const scode = searchParams.get('scode');
if (scode && scode !== stockCode) {
setStockCode(scode);
setInputCode(scode);
if (stockCode !== prevStockCodeRef.current) {
trackStockSearched(stockCode, prevStockCodeRef.current);
prevStockCodeRef.current = stockCode;
}
}, [searchParams, stockCode]);
useEffect(() => {
loadWatchlistStatus();
}, [loadWatchlistStatus]);
const handleSearch = () => {
if (inputCode && inputCode !== stockCode) {
// 🎯 追踪股票搜索
trackStockSearched(inputCode, stockCode);
setStockCode(inputCode);
setSearchParams({ scode: inputCode });
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// 模糊搜索股票(由 onSearch 触发)
const handleStockSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
const results = stockService.fuzzySearch(value, allStocks, 10);
const options = results.map((stock) => ({
value: stock.code,
label: `${stock.code} ${stock.name}`,
}));
setStockOptions(options);
};
// 选中股票
const handleStockSelect = (value) => {
setInputCode(value);
setStockOptions([]);
if (value !== stockCode) {
trackStockSearched(value, stockCode);
setStockCode(value);
setSearchParams({ scode: value });
}
};
const handleWatchlistToggle = async () => {
if (!stockCode) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '无效的股票代码', { stockCode });
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
return;
}
if (!isAuthenticated) {
logger.warn('CompanyIndex', 'handleWatchlistToggle', '用户未登录', { stockCode });
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
return;
}
try {
setIsWatchlistLoading(true);
const base = getApiBase();
if (isInWatchlist) {
logger.debug('CompanyIndex', '准备从自选移除', { stockCode });
const url = base + `/api/account/watchlist/${stockCode}`;
logger.api.request('DELETE', url, { stockCode });
const resp = await fetch(url, {
method: 'DELETE',
credentials: 'include'
});
logger.api.response('DELETE', url, resp.status);
if (!resp.ok) throw new Error('删除失败');
// 🎯 追踪移除自选
trackWatchlistRemoved(stockCode);
setIsInWatchlist(false);
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
} else {
logger.debug('CompanyIndex', '准备添加到自选', { stockCode });
const url = base + '/api/account/watchlist';
const body = { stock_code: stockCode };
logger.api.request('POST', url, body);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
logger.api.response('POST', url, resp.status);
if (!resp.ok) throw new Error('添加失败');
// 🎯 追踪加入自选
trackWatchlistAdded(stockCode);
setIsInWatchlist(true);
toast({ title: '已加入自选', status: 'success', duration: 1500 });
}
} catch (error) {
logger.error('CompanyIndex', 'handleWatchlistToggle', error, { stockCode, isInWatchlist });
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
} finally {
setIsWatchlistLoading(false);
}
};
}, [stockCode, trackStockSearched]);
return (
<Container maxW="container.xl" py={5}>
{/* 页面标题和股票搜索 */}
<VStack align="stretch" spacing={5}>
<Card bg={bgColor} shadow="md">
<CardBody>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="lg">个股详情</Heading>
<Text color="gray.600" fontSize="sm">
查看股票实时行情财务数据和盈利预测
</Text>
</VStack>
<HStack spacing={3}>
<AutoComplete
value={inputCode}
options={stockOptions}
onSearch={handleStockSearch}
onSelect={handleStockSelect}
onChange={(value) => setInputCode(value)}
placeholder="输入股票代码或名称"
style={{ width: 260 }}
size="large"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
<Button
colorScheme="blue"
size="lg"
onClick={handleSearch}
leftIcon={<SearchIcon />}
>
查询
</Button>
<Button
colorScheme={isInWatchlist ? 'yellow' : 'teal'}
variant={isInWatchlist ? 'solid' : 'outline'}
size="lg"
onClick={handleWatchlistToggle}
leftIcon={<StarIcon />}
isLoading={isWatchlistLoading}
>
{isInWatchlist ? '已在自选' : '加入自选'}
</Button>
<IconButton
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="outline"
colorScheme={colorMode === 'light' ? 'blue' : 'yellow'}
size="lg"
aria-label="Toggle color mode"
/>
</HStack>
</HStack>
{/* 当前股票信息 */}
<HStack mt={4} spacing={4}>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
股票代码: {stockCode}
</Badge>
<Text fontSize="sm" color="gray.600">
更新时间: {new Date().toLocaleString()}
</Text>
</HStack>
</CardBody>
</Card>
{/* 数据展示区域 */}
<Card bg={bgColor} shadow="lg">
<CardBody p={0}>
<Tabs
variant="soft-rounded"
colorScheme="blue"
size="lg"
index={currentTabIndex}
onChange={(index) => {
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
// 🎯 追踪 Tab 切换
trackTabChanged(index, tabNames[index], currentTabIndex);
setCurrentTabIndex(index);
}}
>
<TabList p={4} bg={tabBg}>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaInfoCircle} />
<Text>公司概览</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaChartLine} />
<Text>股票行情</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
mr={2}
>
<HStack spacing={2}>
<Icon as={FaMoneyBillWave} />
<Text>财务全景</Text>
</HStack>
</Tab>
<Tab
_selected={{
bg: activeBg,
color: 'white',
transform: 'scale(1.02)',
transition: 'all 0.2s'
}}
>
<HStack spacing={2}>
<Icon as={FaChartBar} />
<Text>盈利预测</Text>
</HStack>
</Tab>
</TabList>
<Divider />
<TabPanels>
<TabPanel p={6}>
<CompanyOverview stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<MarketDataView stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<FinancialPanorama stockCode={stockCode} />
</TabPanel>
<TabPanel p={6}>
<ForecastReport stockCode={stockCode} />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
<Container maxW="container.xl" py={0} bg='#1A202C'>
<VStack align="stretch" spacing={0}>
{/* 页面头部:标题、搜索 */}
<CompanyHeader
inputCode={inputCode}
onInputChange={setInputCode}
onSearch={handleSearch}
onKeyDown={handleKeyDown}
bgColor="#1A202C"
/>
{/* 股票行情卡片:价格、关键指标、主力动态、公司信息 */}
<StockQuoteCard
data={quoteData}
isLoading={isQuoteLoading}
isInWatchlist={isInWatchlist}
isWatchlistLoading={isWatchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
basicInfo={basicInfo}
/>
{/* Tab 切换区域:概览、行情、财务、预测 */}
<CompanyTabs stockCode={stockCode} onTabChange={trackTabChanged} bgColor="#1A202C"/>
</VStack>
</Container>
);
};
export default CompanyIndex;