diff --git a/src/views/Company/STRUCTURE.md b/src/views/Company/STRUCTURE.md new file mode 100644 index 00000000..e5a20fa8 --- /dev/null +++ b/src/views/Company/STRUCTURE.md @@ -0,0 +1,460 @@ +# Company 目录结构说明 + +> 最后更新:2025-12-10 + +## 目录结构 + +``` +src/views/Company/ +├── index.js # 页面入口(95行,纯组合层) +├── STRUCTURE.md # 本文档 +│ +├── components/ # UI 组件 +│ ├── CompanyHeader/ # 页面头部 +│ │ ├── index.js # 组合导出 +│ │ ├── SearchBar.js # 股票搜索栏 +│ │ └── WatchlistButton.js # 自选股按钮 +│ │ +│ ├── CompanyTabs/ # Tab 切换容器 +│ │ ├── index.js # Tab 容器(状态管理 + 内容渲染) +│ │ └── TabNavigation.js # Tab 导航栏 +│ │ +│ ├── CompanyOverview/ # Tab: 公司概览(TypeScript 拆分) +│ │ ├── index.tsx # 主组件(组合层,约 50 行) +│ │ ├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行) +│ │ ├── BasicInfoTab.js # 基本信息 Tab(暂保持 JS) +│ │ ├── DeepAnalysisTab.js # 深度分析 Tab +│ │ ├── NewsEventsTab.js # 新闻事件 Tab +│ │ ├── types.ts # 类型定义(约 50 行) +│ │ ├── utils.ts # 格式化工具(约 20 行) +│ │ └── hooks/ +│ │ └── useCompanyOverviewData.ts # 数据 Hook(约 100 行) +│ │ +│ ├── MarketDataView/ # Tab: 股票行情(TypeScript 拆分) +│ │ ├── index.tsx # 主组件入口(~1049 行) +│ │ ├── types.ts # 类型定义(~383 行) +│ │ ├── 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 # 涨幅分析模态框 +│ │ +│ ├── FinancialPanorama/ # Tab: 财务全景(2153 行,待拆分) +│ │ └── index.js +│ │ +│ └── ForecastReport/ # Tab: 盈利预测(161 行,待拆分) +│ └── index.js +│ +├── hooks/ # 自定义 Hooks +│ ├── useCompanyStock.js # 股票代码管理(URL 同步) +│ ├── useCompanyWatchlist.js # 自选股管理(Redux 集成) +│ └── useCompanyEvents.js # PostHog 事件追踪 +│ +└── 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 + +``` + +#### `CompanyTabs/` - Tab 切换 +| 文件 | 职责 | +|------|------| +| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 | +| `TabNavigation.js` | Tab 导航栏(4个 Tab 按钮) | + +**Props 接口**: +```js + +``` + +--- + +### 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.js # 深度分析 Tab +├── NewsEventsTab.js # 新闻事件 Tab +├── types.ts # 类型定义(约 50 行) +├── utils.ts # 格式化工具(约 20 行) +└── hooks/ + ├── useBasicInfo.ts # 基本信息 Hook(1 API) + ├── useShareholderData.ts # 股权结构 Hook(4 APIs) + ├── useManagementData.ts # 管理团队 Hook(1 API) + ├── useAnnouncementsData.ts # 公告数据 Hook(1 API) + ├── useBranchesData.ts # 分支机构 Hook(1 API) + ├── useDisclosureData.ts # 披露日程 Hook(1 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 +- **关注点分离**:数据加载逻辑提取到 Hook,UI 逻辑保留在组件 +- **类型复用**:统一的类型定义便于在多个文件间共享 +- **懒加载优化**:减少首屏 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; + refetch: () => Promise; + loadMinuteData: () => Promise; +} +``` + +**设计原则**: +- **TypeScript 类型安全**:所有数据结构有完整类型定义 +- **服务层分离**:API 调用统一在 `marketService.ts` 中管理 +- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts` +- **组件复用**:通用组件(ThemedCard、MarkdownRenderer)可在其他模块使用 \ No newline at end of file diff --git a/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx b/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx new file mode 100644 index 00000000..e4677e08 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/AnalysisModal.tsx @@ -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 = ({ analysis, theme }) => { + return ( + + {/* 头部信息 */} + + + {analysis.stock_name} ({analysis.stock_code}) + + + 日期: {analysis.trade_date} + 涨幅: {analysis.rise_rate}% + 收盘价: {analysis.close_price} + + + + {/* 主营业务 */} + {analysis.main_business && ( + + + 主营业务 + + {analysis.main_business} + + )} + + {/* 详细分析 */} + {analysis.rise_reason_detail && ( + + + 详细分析 + + {analysis.rise_reason_detail} + + )} + + {/* 相关公告 */} + {analysis.announcements && analysis.announcements !== '[]' && ( + + + 相关公告 + + {analysis.announcements} + + )} + + {/* 研报引用 */} + {analysis.verification_reports && analysis.verification_reports.length > 0 && ( + + + + + 研报引用 ({analysis.verification_reports.length}) + + + + {analysis.verification_reports.map((report, reportIdx) => ( + + + + + {report.publisher || '未知机构'} + + {report.match_score && ( + + 匹配度: {report.match_score} + + )} + {report.match_ratio != null && report.match_ratio > 0 && ( + + {(report.match_ratio * 100).toFixed(0)}% + + )} + + {report.declare_date && ( + + {report.declare_date.substring(0, 10)} + + )} + + + {report.report_title && ( + + 《{report.report_title}》 + + )} + + {report.author && ( + + 分析师: {report.author} + + )} + + {report.verification_item && ( + + + 验证项: {report.verification_item} + + + )} + + {report.content && ( + + {report.content} + + )} + + ))} + + + )} + + {/* 底部统计 */} + + + 成交量: {formatNumber(analysis.volume)} | 成交额: {formatNumber(analysis.amount)} | 更新时间:{' '} + {analysis.update_time || analysis.create_time || '-'} + + + + ); +}; + +/** + * 涨幅分析模态框组件 + */ +const AnalysisModal: React.FC = ({ isOpen, onClose, content, theme }) => { + return ( + + + + 涨幅分析详情 + + {content} + + + ); +}; + +export default AnalysisModal; diff --git a/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx b/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx new file mode 100644 index 00000000..cfd44f2e --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/MarkdownRenderer.tsx @@ -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 = ({ children, theme }) => { + return ( + + {children} + + ); +}; + +export default MarkdownRenderer; diff --git a/src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx b/src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx new file mode 100644 index 00000000..4c8a2d3f --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/StockSummaryCard.tsx @@ -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 = ({ summary, theme }) => { + if (!summary) return null; + + const { latest_trade, latest_funding, latest_pledge } = summary; + + return ( + + + + {/* 左侧:股票名称和涨跌 */} + + + + + {summary.stock_name} + + + {summary.stock_code} + + + {latest_trade && ( + + + + {latest_trade.close} + + + = 0 ? 'increase' : 'decrease'} + color={latest_trade.change_percent >= 0 ? theme.success : theme.danger} + /> + {Math.abs(latest_trade.change_percent).toFixed(2)}% + + + + )} + + + + {/* 右侧:详细指标 */} + + {/* 交易指标 */} + + {latest_trade && ( + <> + + 成交量 + + {formatNumber(latest_trade.volume, 0)} + + + + 成交额 + + {formatNumber(latest_trade.amount)} + + + + 换手率 + + {formatPercent(latest_trade.turnover_rate)} + + + + 市盈率 + + {latest_trade.pe_ratio || '-'} + + + + )} + + + {/* 融资融券和质押指标 */} + {latest_funding && ( + + + 融资余额 + + {formatNumber(latest_funding.financing_balance)} + + + + 融券余额 + + {formatNumber(latest_funding.securities_balance)} + + + {latest_pledge && ( + + 质押比例 + + {formatPercent(latest_pledge.pledge_ratio)} + + + )} + + )} + + + + + ); +}; + +export default StockSummaryCard; diff --git a/src/views/Company/components/MarketDataView/components/ThemedCard.tsx b/src/views/Company/components/MarketDataView/components/ThemedCard.tsx new file mode 100644 index 00000000..b6f83386 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/ThemedCard.tsx @@ -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 = ({ children, theme, ...props }) => { + return ( + + {children} + + ); +}; + +export default ThemedCard; diff --git a/src/views/Company/components/MarketDataView/components/index.ts b/src/views/Company/components/MarketDataView/components/index.ts new file mode 100644 index 00000000..0fe7d160 --- /dev/null +++ b/src/views/Company/components/MarketDataView/components/index.ts @@ -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'; diff --git a/src/views/Company/components/MarketDataView/constants.ts b/src/views/Company/components/MarketDataView/constants.ts new file mode 100644 index 00000000..beaf149f --- /dev/null +++ b/src/views/Company/components/MarketDataView/constants.ts @@ -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天' }, +]; diff --git a/src/views/Company/components/MarketDataView/hooks/useMarketData.ts b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts new file mode 100644 index 00000000..55247658 --- /dev/null +++ b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts @@ -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(null); + const [tradeData, setTradeData] = useState([]); + const [fundingData, setFundingData] = useState([]); + const [bigDealData, setBigDealData] = useState({ data: [], daily_stats: [] }); + const [unusualData, setUnusualData] = useState({ data: [], grouped_data: [] }); + const [pledgeData, setPledgeData] = useState([]); + const [analysisMap, setAnalysisMap] = useState>({}); + + // 分钟数据状态 + const [minuteData, setMinuteData] = useState(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 = {}; + + 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; diff --git a/src/views/Company/components/MarketDataView/index.js b/src/views/Company/components/MarketDataView/index.js deleted file mode 100644 index f3902d60..00000000 --- a/src/views/Company/components/MarketDataView/index.js +++ /dev/null @@ -1,2060 +0,0 @@ -// src/views/Market/MarketDataPro.jsx -import React, { useState, useEffect, useMemo } from 'react'; -import { logger } from '@utils/logger'; -import { getApiBase } from '@utils/apiConfig'; -import { - Box, - Container, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - Heading, - Text, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - TableContainer, - Stat, - StatLabel, - StatNumber, - StatHelpText, - StatArrow, - SimpleGrid, - Card, - CardBody, - CardHeader, - Spinner, - Center, - Alert, - AlertIcon, - Badge, - VStack, - HStack, - Divider, - Select, - Button, - Tooltip, - Progress, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - useDisclosure, - Input, - Flex, - Tag, - TagLabel, - IconButton, - useToast, - Skeleton, - SkeletonText, - Grid, - GridItem, - ButtonGroup, - Stack, - Icon, - InputGroup, - InputLeftElement, - Spacer, - CircularProgress, - CircularProgressLabel, - chakra, -} from '@chakra-ui/react'; -import { - ChevronDownIcon, - ChevronUpIcon, - InfoIcon, - DownloadIcon, - RepeatIcon, - SearchIcon, - ViewIcon, - TimeIcon, - ArrowUpIcon, - ArrowDownIcon, - StarIcon, - WarningIcon, - LockIcon, - UnlockIcon, - BellIcon, - CalendarIcon, - ExternalLinkIcon, - AddIcon, - MinusIcon, - CheckCircleIcon, - SmallCloseIcon, - MoonIcon, - SunIcon, -} from '@chakra-ui/icons'; -import ReactECharts from 'echarts-for-react'; -import ReactMarkdown from 'react-markdown'; - -// API服务配置 -const API_BASE_URL = getApiBase(); - -// 主题配置 -const themes = { - 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', - }, -}; - -// API服务 -const marketService = { - async apiRequest(url) { - try { - const response = await fetch(`${API_BASE_URL}${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; - } - }, - - async getTradeData(stockCode, days = 60) { - return this.apiRequest(`/api/market/trade/${stockCode}?days=${days}`); - }, - - async getFundingData(stockCode, days = 30) { - return this.apiRequest(`/api/market/funding/${stockCode}?days=${days}`); - }, - - async getBigDealData(stockCode, days = 30) { - return this.apiRequest(`/api/market/bigdeal/${stockCode}?days=${days}`); - }, - - async getUnusualData(stockCode, days = 30) { - return this.apiRequest(`/api/market/unusual/${stockCode}?days=${days}`); - }, - - async getPledgeData(stockCode) { - return this.apiRequest(`/api/market/pledge/${stockCode}`); - }, - - async getMarketSummary(stockCode) { - return this.apiRequest(`/api/market/summary/${stockCode}`); - }, - - async getRiseAnalysis(stockCode, startDate, endDate) { - let url = `/api/market/rise-analysis/${stockCode}`; - if (startDate && endDate) { - url += `?start_date=${startDate}&end_date=${endDate}`; - } - return this.apiRequest(url); - } -}; - -// 格式化工具 -const formatUtils = { - formatNumber(value, decimals = 2) { - if (!value && value !== 0) return '-'; - const num = parseFloat(value); - 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); - }, - - formatPercent(value) { - if (!value && value !== 0) return '-'; - return value.toFixed(2) + '%'; - }, - - formatDate(dateStr) { - if (!dateStr) return '-'; - return dateStr.substring(0, 10); - } -}; - -// 主题化卡片组件 -const ThemedCard = ({ children, theme, ...props }) => { - return ( - - {children} - - ); -}; - -// Markdown渲染组件 -const MarkdownRenderer = ({ children, theme }) => { - return ( - - {children} - - ); -}; - -// 主组件 -const MarketDataView = ({ stockCode: propStockCode }) => { - const toast = useToast(); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [modalContent, setModalContent] = useState(null); - - // 获取当前主题 - const theme = themes.light; - - // 状态管理 - const [stockCode, setStockCode] = useState(propStockCode || '600000'); - const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [selectedPeriod, setSelectedPeriod] = useState(60); - - // 数据状态 - const [summary, setSummary] = useState(null); - const [tradeData, setTradeData] = useState([]); - const [fundingData, setFundingData] = useState([]); - const [bigDealData, setBigDealData] = useState({ data: [], daily_stats: [] }); - const [unusualData, setUnusualData] = useState({ data: [], grouped_data: [] }); - const [pledgeData, setPledgeData] = useState([]); - const [riseAnalysisData, setRiseAnalysisData] = useState([]); - const [analysisMap, setAnalysisMap] = useState({}); - const [minuteData, setMinuteData] = useState([]); - const [minuteLoading, setMinuteLoading] = useState(false); - - // 加载数据 - const loadMarketData = async () => { - logger.debug('MarketDataView', '开始加载市场数据', { stockCode, selectedPeriod }); - setLoading(true); - try { - const [summaryRes, tradeRes, fundingRes, bigDealRes, unusualRes, pledgeRes, riseAnalysisRes] = await Promise.all([ - marketService.getMarketSummary(stockCode), - marketService.getTradeData(stockCode, selectedPeriod), - 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); - if (bigDealRes.success) setBigDealData(bigDealRes); // 设置整个响应对象,包含daily_stats - if (unusualRes.success) setUnusualData(unusualRes); // 设置整个响应对象,包含grouped_data - if (pledgeRes.success) setPledgeData(pledgeRes.data); - if (riseAnalysisRes.success) { - setRiseAnalysisData(riseAnalysisRes.data); - - // 创建分析数据映射 - const tempAnalysisMap = {}; - 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); - } - - // ❌ 移除数据加载成功toast - logger.info('MarketDataView', '市场数据加载成功', { stockCode }); - } catch (error) { - logger.error('MarketDataView', 'loadMarketData', error, { stockCode, selectedPeriod }); - - // ❌ 移除数据加载失败toast - // toast({ title: '数据加载失败', description: error.message, status: 'error', duration: 5000, isClosable: true }); - } finally { - setLoading(false); - } - }; - - // 获取分钟频数据 - const loadMinuteData = async () => { - logger.debug('MarketDataView', '开始加载分钟频数据', { stockCode }); - setMinuteLoading(true); - try { - const response = await fetch( - `${API_BASE_URL}/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)) { - setMinuteData(data); - logger.info('MarketDataView', '分钟频数据加载成功', { stockCode, dataPoints: data.data.length }); - } else { - setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' }); - logger.warn('MarketDataView', '分钟频数据为空', { stockCode }); - } - - } catch (error) { - logger.error('MarketDataView', 'loadMinuteData', error, { stockCode }); - - // ❌ 移除分钟数据加载失败toast - // toast({ title: '分钟数据加载失败', description: error.message, status: 'error', duration: 3000, isClosable: true }); - setMinuteData({ data: [], code: stockCode, name: '', trade_date: '', type: 'minute' }); - } finally { - setMinuteLoading(false); - } - }; - - // 监听props中的stockCode变化 - useEffect(() => { - if (propStockCode && propStockCode !== stockCode) { - setStockCode(propStockCode); - } - }, [propStockCode, stockCode]); - - useEffect(() => { - if (stockCode) { - loadMarketData(); - // 自动加载分钟频数据 - loadMinuteData(); - } - }, [stockCode, selectedPeriod]); - - // K线图配置 - const getKLineOption = () => { - 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 ma5 = calculateMA(tradeData.map(item => item.close), 5); - const ma10 = calculateMA(tradeData.map(item => item.close), 10); - const ma20 = calculateMA(tradeData.map(item => item.close), 20); - - // 创建涨幅分析标记点 - const scatterData = []; - - // 使用组件级别的 analysisMap - 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 - }, - formatter: function(params) { - const dataIndex = params[0]?.dataIndex; - let result = `${params[0]?.name || ''}
`; - - params.forEach(param => { - if (param.seriesName === '涨幅分析' && analysisMap[dataIndex]) { - const analysis = analysisMap[dataIndex]; - result = `
- ${analysis.stock_name} (${analysis.stock_code})
- 日期: ${analysis.trade_date}
- 涨幅: ${analysis.rise_rate}%
- 收盘价: ${analysis.close_price}
-
- 涨幅原因:
-
${analysis.rise_reason_brief || '暂无分析'}
-
-
点击查看详细分析
-
`; - } else if (param.seriesName === 'K线') { - const [open, close, low, high] = param.data; - result += `${param.marker} ${param.seriesName}
`; - result += `开盘: ${open}
`; - result += `收盘: ${close}
`; - result += `最低: ${low}
`; - result += `最高: ${high}
`; - } else if (param.value != null) { - result += `${param.marker} ${param.seriesName}: ${param.value}
`; - } - }); - - return result; - } - }, - 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, // 确保显示在最上层 - cursor: 'pointer' // 显示为可点击 - }, - { - name: '成交量', - type: 'bar', - xAxisIndex: 1, - yAxisIndex: 1, - data: volumes, - itemStyle: { - color: (params) => { - const item = tradeData[params.dataIndex]; - return item.change_percent >= 0 ? - 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; - } - } - } - ] - }; - }; - - // 分钟频K线图配置 - const getMinuteKLineOption = () => { - 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 avgPrice = calculateMA(minuteData.data.map(item => item.close), 5); // 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) => { - let result = params[0].name + '
'; - params.forEach(param => { - if (param.seriesName === '分钟K线') { - const [open, close, low, high] = param.data; - const changePercent = openPrice > 0 ? ((close - openPrice) / openPrice * 100).toFixed(2) : '0.00'; - result += `${param.marker} ${param.seriesName}
`; - result += `开盘: ${open.toFixed(2)}
`; - result += `收盘: ${close.toFixed(2)}
`; - result += `最高: ${high.toFixed(2)}
`; - result += `最低: ${low.toFixed(2)}
`; - result += `涨跌: ${changePercent}%
`; - } else if (param.seriesName === '均价线') { - result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}
`; - } else if (param.seriesName === '成交量') { - result += `${param.marker} ${param.seriesName}: ${formatUtils.formatNumber(param.value, 0)}
`; - } - }); - 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, - scale: true, - boundaryGap: false, - axisLine: { lineStyle: { color: theme.textMuted } }, - axisLabel: { - color: theme.textMuted, - fontSize: 10, - interval: 'auto' - }, - splitLine: { show: false } - }, - { - type: 'category', - gridIndex: 1, - data: times, - scale: true, - 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) => { - const item = minuteData.data[params.dataIndex]; - return item.close >= item.open ? - 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; - } - } - } - ] - }; - }; - - // 计算移动平均线 - const calculateMA = (data, period) => { - const result = []; - 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; - }; - - // 融资融券图表配置 - const getFundingOption = () => { - 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) => { - let result = params[0].name + '
'; - params.forEach(param => { - result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿
`; - }); - 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 - } - ] - }; - }; - - // 股权质押图表配置 - const getPledgeOption = () => { - 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 - }] - }, - barBorderRadius: [5, 5, 0, 0] - }, - data: counts - } - ] - }; - }; - - return ( - - - - {/* 股票概览 */} - {summary && ( - - - - - - - - {summary.stock_name} - - - {summary.stock_code} - - - {summary.latest_trade && ( - - - - {summary.latest_trade.close} - - - = 0 ? 'increase' : 'decrease'} - color={summary.latest_trade.change_percent >= 0 ? theme.success : theme.danger} - /> - {Math.abs(summary.latest_trade.change_percent).toFixed(2)}% - - - - )} - - - - - - {summary.latest_trade && ( - <> - - 成交量 - - {formatUtils.formatNumber(summary.latest_trade.volume, 0)} - - - - 成交额 - - {formatUtils.formatNumber(summary.latest_trade.amount)} - - - - 换手率 - - {formatUtils.formatPercent(summary.latest_trade.turnover_rate)} - - - - 市盈率 - - {summary.latest_trade.pe_ratio || '-'} - - - - )} - - - {summary.latest_funding && ( - - - 融资余额 - - {formatUtils.formatNumber(summary.latest_funding.financing_balance)} - - - - 融券余额 - - {formatUtils.formatNumber(summary.latest_funding.securities_balance)} - - - {summary.latest_pledge && ( - - 质押比例 - - {formatUtils.formatPercent(summary.latest_pledge.pledge_ratio)} - - - )} - - )} - - - - - )} - - {/* 主要内容区域 */} - {loading ? ( - - -
- - - 数据加载中... - -
-
-
- ) : ( - - - - - - - - 交易数据 - - - - - - 融资融券 - - - - - - 大宗交易 - - - - - - 龙虎榜 - - - - - - 股权质押 - - - - - - - 时间范围: - - - - - - - - {/* 交易数据 */} - - - - - {tradeData.length > 0 && ( - - { - if (params.seriesName === '涨幅分析' && params.data) { - const dataIndex = params.data[0]; // scatter数据格式是[x, y] - const analysis = analysisMap[dataIndex]; - - if (analysis) { - setModalContent( - - - {analysis.stock_name} ({analysis.stock_code}) - - 日期: {analysis.trade_date} - 涨幅: {analysis.rise_rate}% - 收盘价: {analysis.close_price} - - - - {analysis.main_business && ( - - 主营业务 - {analysis.main_business} - - )} - - - {analysis.rise_reason_detail && ( - - 详细分析 - - {analysis.rise_reason_detail} - - - )} - - {analysis.announcements && analysis.announcements !== '[]' && ( - - 相关公告 - - {analysis.announcements} - - - )} - - {/* 研报引用展示 */} - {analysis.verification_reports && analysis.verification_reports.length > 0 && ( - - - - - 研报引用 ({analysis.verification_reports.length}) - - - - {analysis.verification_reports.map((report, reportIdx) => ( - - - - - {report.publisher || '未知机构'} - - {report.match_score && ( - - 匹配度: {report.match_score} - - )} - {report.match_ratio != null && report.match_ratio > 0 && ( - - {(report.match_ratio * 100).toFixed(0)}% - - )} - - {report.declare_date && ( - - {report.declare_date.substring(0, 10)} - - )} - - - {report.report_title && ( - - 《{report.report_title}》 - - )} - - {report.author && ( - - 分析师: {report.author} - - )} - - {report.verification_item && ( - - - 验证项: {report.verification_item} - - - )} - - {report.content && ( - - {report.content} - - )} - - ))} - - - )} - - - - 成交量: {formatUtils.formatNumber(analysis.volume)} | - 成交额: {formatUtils.formatNumber(analysis.amount)} | - 更新时间: {analysis.update_time || analysis.create_time || '-'} - - - - ); - onOpen(); - } - } - } - }} - /> - - )} - - - - {/* 当日分钟频数据 */} - - - - - - - 当日分钟频数据 - - {minuteData && minuteData.trade_date && ( - - {minuteData.trade_date} - - )} - - - - - - {minuteLoading ? ( -
- - - - 加载分钟频数据中... - - -
- ) : minuteData && minuteData.data && minuteData.data.length > 0 ? ( - - {/* 分钟K线图 */} - - - - - {/* 分钟数据统计 */} - - - - - - 开盘价 - - - - {minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'} - - - - - - - 当前价 - - - = minuteData.data[0]?.open ? theme.success : theme.danger} - fontSize="lg" - > - {minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'} - - - = minuteData.data[0]?.open ? 'increase' : 'decrease'} - /> - {(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null) - ? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2) - : '0.00'}% - - - - - - - 最高价 - - - - {(() => { - const highs = minuteData.data.map(item => item.high).filter(h => h != null); - return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-'; - })()} - - - - - - - 最低价 - - - - {(() => { - const lows = minuteData.data.map(item => item.low).filter(l => l != null); - return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-'; - })()} - - - - - {/* 成交量分析 */} - - - - 成交数据分析 - - - - 总成交量: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.volume, 0), 0)} - - - 总成交额: {formatUtils.formatNumber(minuteData.data.reduce((sum, item) => sum + item.amount, 0))} - - - - - - - - 活跃时段 - - - {(() => { - const maxVolume = Math.max(...minuteData.data.map(item => item.volume)); - const activeTime = minuteData.data.find(item => item.volume === maxVolume); - return activeTime ? `${activeTime.time} (${formatUtils.formatNumber(maxVolume, 0)})` : '-'; - })()} - - - - - 平均价格 - - - {(() => { - const closes = minuteData.data.map(item => item.close).filter(c => c != null); - return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-'; - })()} - - - - - 数据点数 - - - {minuteData.data.length} 个分钟 - - - - - - ) : ( -
- - - - - 暂无分钟频数据 - - - 点击"获取分钟数据"按钮加载最新的交易日分钟频数据 - - - -
- )} -
-
- - - - - 交易明细 - - - - - - - - - - - - - - - - - - - {tradeData.slice(-10).reverse().map((item, idx) => ( - - - - - - - - - - - ))} - -
日期开盘最高最低收盘涨跌幅成交量成交额
{item.date}{item.open}{item.high}{item.low}{item.close}= 0 ? theme.success : theme.danger} fontWeight="bold"> - {item.change_percent >= 0 ? '+' : ''}{formatUtils.formatPercent(item.change_percent)} - {formatUtils.formatNumber(item.volume, 0)}{formatUtils.formatNumber(item.amount)}
-
-
-
-
-
- - {/* 融资融券 */} - - - - - {fundingData.length > 0 && ( - - - - )} - - - - - - - - 融资数据 - - - - - {fundingData.slice(-5).reverse().map((item, idx) => ( - - - {item.date} - - - {formatUtils.formatNumber(item.financing.balance)} - - - 买入{formatUtils.formatNumber(item.financing.buy)} / 偿还{formatUtils.formatNumber(item.financing.repay)} - - - - - ))} - - - - - - - - 融券数据 - - - - - {fundingData.slice(-5).reverse().map((item, idx) => ( - - - {item.date} - - - {formatUtils.formatNumber(item.securities.balance)} - - - 卖出{formatUtils.formatNumber(item.securities.sell)} / 偿还{formatUtils.formatNumber(item.securities.repay)} - - - - - ))} - - - - - - - - {/* 大宗交易 */} - - - - - 大宗交易记录 - - - - {bigDealData && bigDealData.daily_stats && bigDealData.daily_stats.length > 0 ? ( - - {bigDealData.daily_stats.map((dayStats, idx) => ( - - - - {dayStats.date} - - - - 交易笔数: {dayStats.count} - - - 成交量: {formatUtils.formatNumber(dayStats.total_volume)}万股 - - - 成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元 - - - 均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元 - - - - - {/* 显示当日交易明细 */} - {dayStats.deals && dayStats.deals.length > 0 && ( - - - - - - - - - - - - - {dayStats.deals.map((deal, i) => ( - - - - - - - - ))} - -
买方营业部卖方营业部成交价成交量(万股)成交额(万元)
- - {deal.buyer_dept || '-'} - - - - {deal.seller_dept || '-'} - - - {deal.price != null ? deal.price.toFixed(2) : '-'} - - {deal.volume != null ? deal.volume.toFixed(2) : '-'} - - {deal.amount != null ? deal.amount.toFixed(2) : '-'} -
-
- )} -
- ))} -
- ) : ( -
- 暂无大宗交易数据 -
- )} -
-
-
- - {/* 龙虎榜 */} - - - - - 龙虎榜数据 - - - - {unusualData && unusualData.grouped_data && unusualData.grouped_data.length > 0 ? ( - - {unusualData.grouped_data.map((dayData, idx) => ( - - - - {dayData.date} - - - - 买入: {formatUtils.formatNumber(dayData.total_buy)} - - - 卖出: {formatUtils.formatNumber(dayData.total_sell)} - - 0 ? 'red' : 'green'} fontSize="md"> - 净额: {formatUtils.formatNumber(dayData.net_amount)} - - - - - - - - 买入前五 - - - {dayData.buyers && dayData.buyers.length > 0 ? ( - dayData.buyers.slice(0, 5).map((buyer, i) => ( - - - {buyer.dept_name} - - - {formatUtils.formatNumber(buyer.buy_amount)} - - - )) - ) : ( - 暂无数据 - )} - - - - - - 卖出前五 - - - {dayData.sellers && dayData.sellers.length > 0 ? ( - dayData.sellers.slice(0, 5).map((seller, i) => ( - - - {seller.dept_name} - - - {formatUtils.formatNumber(seller.sell_amount)} - - - )) - ) : ( - 暂无数据 - )} - - - - - {/* 信息类型标签 */} - - 类型: - {dayData.info_types && dayData.info_types.map((type, i) => ( - - {type} - - ))} - - - ))} - - ) : ( -
- 暂无龙虎榜数据 -
- )} -
-
-
- - {/* 股权质押 */} - - - - - {pledgeData.length > 0 && ( - - - - )} - - - - - - - 质押明细 - - - - - - - - - - - - - - - - - - {Array.isArray(pledgeData) && pledgeData.length > 0 ? ( - pledgeData.map((item, idx) => ( - - - - - - - - - - )) - ) : ( - - - - )} - -
日期无限售质押(万股)限售质押(万股)质押总量(万股)总股本(万股)质押比例质押笔数
{item.end_date}{formatUtils.formatNumber(item.unrestricted_pledge, 0)}{formatUtils.formatNumber(item.restricted_pledge, 0)}{formatUtils.formatNumber(item.total_pledge, 0)}{formatUtils.formatNumber(item.total_shares, 0)} - {formatUtils.formatPercent(item.pledge_ratio)} - {item.pledge_count}
- 暂无数据 -
-
-
-
-
-
-
-
- )} -
-
- - {/* 模态框 */} - - - - 详细信息 - - - {modalContent} - - - -
- ); -}; - -export default MarketDataView; \ No newline at end of file diff --git a/src/views/Company/components/MarketDataView/index.tsx b/src/views/Company/components/MarketDataView/index.tsx new file mode 100644 index 00000000..a422031a --- /dev/null +++ b/src/views/Company/components/MarketDataView/index.tsx @@ -0,0 +1,1049 @@ +// src/views/Company/components/MarketDataView/index.tsx +// MarketDataView 主组件 - 股票市场数据综合展示 + +import React, { useState, useEffect, ReactNode } from 'react'; +import { + Box, + Container, + Tabs, + TabList, + TabPanels, + Tab, + TabPanel, + Text, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + TableContainer, + Stat, + StatLabel, + StatNumber, + StatHelpText, + StatArrow, + SimpleGrid, + CardBody, + CardHeader, + Spinner, + Center, + Badge, + VStack, + HStack, + Select, + Button, + Tooltip, + Grid, + GridItem, + Icon, + Heading, + Tag, + useDisclosure, +} from '@chakra-ui/react'; +import { + ChevronDownIcon, + ChevronUpIcon, + InfoIcon, + RepeatIcon, + TimeIcon, + ArrowUpIcon, + ArrowDownIcon, + StarIcon, + LockIcon, + UnlockIcon, +} from '@chakra-ui/icons'; +import ReactECharts from 'echarts-for-react'; + +// 内部模块导入 +import { themes, DEFAULT_PERIOD, PERIOD_OPTIONS } from './constants'; +import { useMarketData } from './hooks/useMarketData'; +import { + formatNumber, + formatPercent, +} from './utils/formatUtils'; +import { + getKLineOption, + getMinuteKLineOption, + getFundingOption, + getPledgeOption, +} from './utils/chartOptions'; +import { + ThemedCard, + StockSummaryCard, + AnalysisModal, + AnalysisContent, +} from './components'; +import type { MarketDataViewProps, RiseAnalysis } from './types'; + +/** + * MarketDataView 主组件 + * 展示股票的市场数据:交易数据、融资融券、大宗交易、龙虎榜、股权质押 + */ +const MarketDataView: React.FC = ({ stockCode: propStockCode }) => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const [modalContent, setModalContent] = useState(null); + + // 获取当前主题 + const theme = themes.light; + + // 状态管理 + const [stockCode, setStockCode] = useState(propStockCode || '600000'); + const [activeTab, setActiveTab] = useState(0); + const [selectedPeriod, setSelectedPeriod] = useState(DEFAULT_PERIOD); + + // 使用自定义 Hook 获取数据 + const { + loading, + summary, + tradeData, + fundingData, + bigDealData, + unusualData, + pledgeData, + minuteData, + minuteLoading, + analysisMap, + refetch, + loadMinuteData, + } = useMarketData(stockCode, selectedPeriod); + + // 监听 props 中的 stockCode 变化 + useEffect(() => { + if (propStockCode && propStockCode !== stockCode) { + setStockCode(propStockCode); + } + }, [propStockCode, stockCode]); + + // 处理图表点击事件 + const handleChartClick = (params: { seriesName?: string; data?: [number, number] }) => { + if (params.seriesName === '涨幅分析' && params.data) { + const dataIndex = params.data[0]; + const analysis = analysisMap[dataIndex]; + + if (analysis) { + setModalContent(); + onOpen(); + } + } + }; + + return ( + + + + {/* 股票概览 */} + {summary && } + + {/* 主要内容区域 */} + {loading ? ( + + +
+ + + 数据加载中... + +
+
+
+ ) : ( + + {/* Tab 导航栏 */} + + + + + + + 交易数据 + + + + + + 融资融券 + + + + + + 大宗交易 + + + + + + 龙虎榜 + + + + + + 股权质押 + + + + + {/* 时间范围选择和刷新按钮 */} + + + 时间范围: + + + + + + + + + {/* 交易数据 Tab */} + + + {/* K线图 */} + + + {tradeData.length > 0 && ( + + + + )} + + + + {/* 分钟K线数据 */} + + + + + + + 当日分钟频数据 + + {minuteData && minuteData.trade_date && ( + + {minuteData.trade_date} + + )} + + + + + + {minuteLoading ? ( +
+ + + + 加载分钟频数据中... + + +
+ ) : minuteData && minuteData.data && minuteData.data.length > 0 ? ( + + + + + + {/* 分钟数据统计 */} + + + + + + 开盘价 + + + + {minuteData.data[0]?.open?.toFixed(2) || '-'} + + + + + + + 当前价 + + + = + (minuteData.data[0]?.open || 0) + ? theme.success + : theme.danger + } + fontSize="lg" + > + {minuteData.data[minuteData.data.length - 1]?.close?.toFixed(2) || + '-'} + + + = + (minuteData.data[0]?.open || 0) + ? 'increase' + : 'decrease' + } + /> + {(() => { + const lastClose = + minuteData.data[minuteData.data.length - 1]?.close; + const firstOpen = minuteData.data[0]?.open; + if (lastClose && firstOpen) { + return Math.abs( + ((lastClose - firstOpen) / firstOpen) * 100 + ).toFixed(2); + } + return '0.00'; + })()} + % + + + + + + + 最高价 + + + + {Math.max( + ...minuteData.data.map((item) => item.high).filter(Boolean) + ).toFixed(2)} + + + + + + + 最低价 + + + + {Math.min( + ...minuteData.data.map((item) => item.low).filter(Boolean) + ).toFixed(2)} + + + + + {/* 成交数据分析 */} + + + + 成交数据分析 + + + + 总成交量:{' '} + {formatNumber( + minuteData.data.reduce((sum, item) => sum + item.volume, 0), + 0 + )} + + + 总成交额:{' '} + {formatNumber( + minuteData.data.reduce((sum, item) => sum + item.amount, 0) + )} + + + + + + + + 活跃时段 + + + {(() => { + const maxVolume = Math.max( + ...minuteData.data.map((item) => item.volume) + ); + const activeTime = minuteData.data.find( + (item) => item.volume === maxVolume + ); + return activeTime + ? `${activeTime.time} (${formatNumber(maxVolume, 0)})` + : '-'; + })()} + + + + + 平均价格 + + + {( + minuteData.data.reduce((sum, item) => sum + item.close, 0) / + minuteData.data.length + ).toFixed(2)} + + + + + 数据点数 + + + {minuteData.data.length} 个分钟 + + + + + + ) : ( +
+ + + + + 暂无分钟频数据 + + + 点击"获取分钟数据"按钮加载最新的交易日分钟频数据 + + + +
+ )} +
+
+ + {/* 交易明细表格 */} + + + + 交易明细 + + + + + + + + + + + + + + + + + + + {tradeData + .slice(-10) + .reverse() + .map((item, idx) => ( + + + + + + + + + + + ))} + +
日期 + 开盘 + + 最高 + + 最低 + + 收盘 + + 涨跌幅 + + 成交量 + + 成交额 +
{item.date} + {item.open} + + {item.high} + + {item.low} + + {item.close} + = 0 ? theme.success : theme.danger + } + fontWeight="bold" + > + {item.change_percent >= 0 ? '+' : ''} + {formatPercent(item.change_percent)} + + {formatNumber(item.volume, 0)} + + {formatNumber(item.amount)} +
+
+
+
+
+
+ + {/* 融资融券 Tab */} + + + + + {fundingData.length > 0 && ( + + + + )} + + + + + {/* 融资数据 */} + + + + 融资数据 + + + + + {fundingData + .slice(-5) + .reverse() + .map((item, idx) => ( + + + {item.date} + + + {formatNumber(item.financing.balance)} + + + 买入{formatNumber(item.financing.buy)} / 偿还 + {formatNumber(item.financing.repay)} + + + + + ))} + + + + + {/* 融券数据 */} + + + + 融券数据 + + + + + {fundingData + .slice(-5) + .reverse() + .map((item, idx) => ( + + + {item.date} + + + {formatNumber(item.securities.balance)} + + + 卖出{formatNumber(item.securities.sell)} / 偿还 + {formatNumber(item.securities.repay)} + + + + + ))} + + + + + + + + {/* 大宗交易 Tab */} + + + + + 大宗交易记录 + + + + {bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? ( + + {bigDealData.daily_stats.map((dayStats, idx) => ( + + + + {dayStats.date} + + + + 交易笔数: {dayStats.count} + + + 成交量: {formatNumber(dayStats.total_volume)}万股 + + + 成交额: {formatNumber(dayStats.total_amount)}万元 + + + 均价: {dayStats.avg_price?.toFixed(2) || '-'}元 + + + + + {dayStats.deals && dayStats.deals.length > 0 && ( + + + + + + + + + + + + + {dayStats.deals.map((deal, i) => ( + + + + + + + + ))} + +
买方营业部卖方营业部 + 成交价 + + 成交量(万股) + + 成交额(万元) +
+ + {deal.buyer_dept || '-'} + + + + {deal.seller_dept || '-'} + + + {deal.price?.toFixed(2) || '-'} + + {deal.volume?.toFixed(2) || '-'} + + {deal.amount?.toFixed(2) || '-'} +
+
+ )} +
+ ))} +
+ ) : ( +
+ 暂无大宗交易数据 +
+ )} +
+
+
+ + {/* 龙虎榜 Tab */} + + + + + 龙虎榜数据 + + + + {unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? ( + + {unusualData.grouped_data.map((dayData, idx) => ( + + + + {dayData.date} + + + + 买入: {formatNumber(dayData.total_buy)} + + + 卖出: {formatNumber(dayData.total_sell)} + + 0 ? 'red' : 'green'} + fontSize="md" + > + 净额: {formatNumber(dayData.net_amount)} + + + + + + + + 买入前五 + + + {dayData.buyers && dayData.buyers.length > 0 ? ( + dayData.buyers.slice(0, 5).map((buyer, i) => ( + + + {buyer.dept_name} + + + {formatNumber(buyer.buy_amount)} + + + )) + ) : ( + + 暂无数据 + + )} + + + + + + 卖出前五 + + + {dayData.sellers && dayData.sellers.length > 0 ? ( + dayData.sellers.slice(0, 5).map((seller, i) => ( + + + {seller.dept_name} + + + {formatNumber(seller.sell_amount)} + + + )) + ) : ( + + 暂无数据 + + )} + + + + + {/* 信息类型标签 */} + + + 类型: + + {dayData.info_types?.map((type, i) => ( + + {type} + + ))} + + + ))} + + ) : ( +
+ 暂无龙虎榜数据 +
+ )} +
+
+
+ + {/* 股权质押 Tab */} + + + + + {pledgeData.length > 0 && ( + + + + )} + + + + + + + 质押明细 + + + + + + + + + + + + + + + + + + {pledgeData.length > 0 ? ( + pledgeData.map((item, idx) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
日期 + 无限售质押(万股) + + 限售质押(万股) + + 质押总量(万股) + + 总股本(万股) + + 质押比例 + + 质押笔数 +
{item.end_date} + {formatNumber(item.unrestricted_pledge, 0)} + + {formatNumber(item.restricted_pledge, 0)} + + {formatNumber(item.total_pledge, 0)} + + {formatNumber(item.total_shares, 0)} + + {formatPercent(item.pledge_ratio)} + + {item.pledge_count} +
+ + 暂无数据 + +
+
+
+
+
+
+
+
+ )} +
+
+ + {/* 涨幅分析模态框 */} + +
+ ); +}; + +export default MarketDataView; diff --git a/src/views/Company/components/MarketDataView/services/marketService.ts b/src/views/Company/components/MarketDataView/services/marketService.ts new file mode 100644 index 00000000..1050d9f0 --- /dev/null +++ b/src/views/Company/components/MarketDataView/services/marketService.ts @@ -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 { + success: boolean; + data: T; + message?: string; +} + +/** + * API 基础 URL + */ +const getBaseUrl = (): string => getApiBase(); + +/** + * 通用 API 请求函数 + */ +const apiRequest = async (url: string): Promise> => { + 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> { + return apiRequest(`/api/market/summary/${stockCode}`); + }, + + /** + * 获取交易日数据 + * @param stockCode 股票代码 + * @param days 天数,默认 60 天 + */ + async getTradeData(stockCode: string, days: number = 60): Promise> { + return apiRequest(`/api/market/trade/${stockCode}?days=${days}`); + }, + + /** + * 获取融资融券数据 + * @param stockCode 股票代码 + * @param days 天数,默认 30 天 + */ + async getFundingData(stockCode: string, days: number = 30): Promise> { + return apiRequest(`/api/market/funding/${stockCode}?days=${days}`); + }, + + /** + * 获取大宗交易数据 + * @param stockCode 股票代码 + * @param days 天数,默认 30 天 + */ + async getBigDealData(stockCode: string, days: number = 30): Promise { + 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 { + 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> { + return apiRequest(`/api/market/pledge/${stockCode}`); + }, + + /** + * 获取涨幅分析数据 + * @param stockCode 股票代码 + * @param startDate 开始日期(可选) + * @param endDate 结束日期(可选) + */ + async getRiseAnalysis( + stockCode: string, + startDate?: string, + endDate?: string + ): Promise> { + let url = `/api/market/rise-analysis/${stockCode}`; + if (startDate && endDate) { + url += `?start_date=${startDate}&end_date=${endDate}`; + } + return apiRequest(url); + }, + + /** + * 获取分钟K线数据 + * @param stockCode 股票代码 + */ + async getMinuteData(stockCode: string): Promise { + 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; diff --git a/src/views/Company/components/MarketDataView/types.ts b/src/views/Company/components/MarketDataView/types.ts new file mode 100644 index 00000000..a6604cf4 --- /dev/null +++ b/src/views/Company/components/MarketDataView/types.ts @@ -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; + onLoadMinuteData: () => void; + onAnalysisClick: (analysis: RiseAnalysis) => void; +} + +/** + * KLineChart 组件 Props + */ +export interface KLineChartProps { + theme: Theme; + tradeData: TradeDayData[]; + analysisMap: Record; + 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; + refetch: () => Promise; + loadMinuteData: () => Promise; +} diff --git a/src/views/Company/components/MarketDataView/utils/chartOptions.ts b/src/views/Company/components/MarketDataView/utils/chartOptions.ts new file mode 100644 index 00000000..0d6f7a6c --- /dev/null +++ b/src/views/Company/components/MarketDataView/utils/chartOptions.ts @@ -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 +): 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 + '
'; + 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}
`; + result += `开盘: ${open.toFixed(2)}
`; + result += `收盘: ${close.toFixed(2)}
`; + result += `最高: ${high.toFixed(2)}
`; + result += `最低: ${low.toFixed(2)}
`; + result += `涨跌: ${changePercent}%
`; + } else if (param.seriesName === '均价线') { + result += `${param.marker} ${param.seriesName}: ${(param.value as number).toFixed(2)}
`; + } else if (param.seriesName === '成交量') { + result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value as number, 0)}
`; + } + }); + 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 + '
'; + paramsArr.forEach((param) => { + result += `${param.marker} ${param.seriesName}: ${param.value.toFixed(2)}亿
`; + }); + 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, +}; diff --git a/src/views/Company/components/MarketDataView/utils/formatUtils.ts b/src/views/Company/components/MarketDataView/utils/formatUtils.ts new file mode 100644 index 00000000..7f1df325 --- /dev/null +++ b/src/views/Company/components/MarketDataView/utils/formatUtils.ts @@ -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;