refactor: MarketDataView TypeScript 重构 - 2060 行拆分为 12 个模块

- 将原 index.js (2060 行) 重构为 TypeScript 模块化架构
- 新增 types.ts: 383 行类型定义 (Theme, TradeDayData, MinuteData 等)
- 新增 services/marketService.ts: API 服务层封装
- 新增 hooks/useMarketData.ts: 数据获取 Hook
- 新增 utils/formatUtils.ts: 格式化工具函数
- 新增 utils/chartOptions.ts: ECharts 图表配置生成器 (698 行)
- 新增 components/: ThemedCard, MarkdownRenderer, StockSummaryCard, AnalysisModal
- 添加 Company/STRUCTURE.md 目录结构文档

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-10 15:14:23 +08:00
parent 722d038b56
commit bfb6ef63d0
14 changed files with 3605 additions and 2060 deletions

View File

@@ -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
<CompanyHeader
stockCode={string} // 当前股票代码
inputCode={string} // 输入框值
onInputChange={func} // 输入变化回调
onSearch={func} // 搜索回调
onKeyPress={func} // 键盘事件回调
isInWatchlist={bool} // 是否在自选中
isWatchlistLoading={bool} // 自选操作加载中
onWatchlistToggle={func} // 自选切换回调
bgColor={string} // 背景色
/>
```
#### `CompanyTabs/` - Tab 切换
| 文件 | 职责 |
|------|------|
| `index.js` | Tab 容器,管理切换状态,渲染 Tab 内容 |
| `TabNavigation.js` | Tab 导航栏4个 Tab 按钮) |
**Props 接口**
```js
<CompanyTabs
stockCode={string} // 当前股票代码
onTabChange={func} // Tab 变更回调 (index, tabName, prevIndex)
bgColor={string} // 背景色
/>
```
---
### Constants 目录
#### `constants/index.js` - 常量配置
- `COMPANY_TABS` - Tab 配置数组key, name, icon
- `TAB_SELECTED_STYLE` - Tab 选中样式
- `TOAST_MESSAGES` - Toast 消息配置
- `DEFAULT_STOCK_CODE` - 默认股票代码 ('000001')
- `URL_PARAM_NAME` - URL 参数名 ('scode')
- `getTabNameByIndex()` - 根据索引获取 Tab 名称
---
### Tab 内容组件(`components/` 目录下)
| 组件 | Tab 名称 | 职责 | 代码行数 |
|------|----------|------|----------|
| `CompanyOverview/` | 公司概览 | 公司基本信息、相关事件 | - |
| `MarketDataView/` | 股票行情 | K线图、实时行情 | - |
| `FinancialPanorama/` | 财务全景 | 财务报表、指标分析 | 2153 行 |
| `ForecastReport/` | 盈利预测 | 分析师预测、目标价 | 161 行 |
> 📌 所有 Tab 内容组件已文件夹化并统一放置在 `components/` 目录下
---
## 数据流示意
```
┌─────────────────────────────────────────────────────────────┐
│ index.js (页面入口) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ useCompanyStock │ │useCompanyWatchlist│ │useCompanyEvents│
│ │ │ │ │ │ │ │
│ │ • stockCode │ │ • isInWatchlist │ │ • track* │ │
│ │ • inputCode │ │ • toggle │ │ functions │ │
│ │ • handleSearch │ │ │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────┬─────────┴───────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyHeader │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ SearchBar │ │ WatchlistButton │ │ │
│ │ └─────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ CompanyTabs │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabNavigation │ │ │
│ │ │ [概览] [行情] [财务] [预测] │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ TabPanels │ │ │
│ │ │ • CompanyOverview │ │ │
│ │ │ • MarketDataView │ │ │
│ │ │ • FinancialPanorama │ │ │
│ │ │ • ForecastReport │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 重构记录
### 2025-12-09 重构
**改动概述**
- `index.js` 从 **349 行** 精简至 **95 行**(减少 73%
- 提取 **3 个自定义 Hooks**
- 提取 **2 个组件目录**CompanyHeader、CompanyTabs
- 抽离常量到 `constants/index.js`
**修复的问题**
1. **无限循环 Bug**`useCompanyWatchlist` 中使用 `useRef` 防止重复初始化
2. **Hook 调用顺序**:确保 `useCompanyEvents` 在 `useCompanyStock` 之后调用(依赖 stockCode
3. **类型检查**`CompanyOverview.js` 中 `event.keywords` 渲染时添加类型检查,支持字符串和对象两种格式
**设计原则**
- **关注点分离**:每个 Hook 只负责单一职责
- **纯组合层**index.js 不包含业务逻辑,只负责组合
- **Props 透传**:通过 Props 将状态和回调传递给子组件
### 2025-12-09 文件夹化
**改动概述**
- 所有 4 个 Tab 内容组件统一移动到 `components/` 目录
- `CompanyOverview.js` → `components/CompanyOverview/index.js`
- `MarketDataView.js` → `components/MarketDataView/index.js`
- `FinancialPanorama.js` → `components/FinancialPanorama/index.js`2153 行)
- `ForecastReport.js` → `components/ForecastReport/index.js`161 行)
- 更新 `CompanyTabs/index.js` 中的导入路径
**目的**
- 统一目录结构,所有组件都在 `components/` 下
- 为后期组件拆分做准备便于添加子组件、hooks、utils 等
### 2025-12-10 CompanyOverview 拆分TypeScript
**改动概述**
- `CompanyOverview/index.js` 从 **330 行** 精简至 **50 行**(减少 85%
- 采用 **TypeScript** 进行拆分,提高类型安全性
- 提取 **1 个自定义 Hook**`useCompanyOverviewData`
- 提取 **1 个子组件**`CompanyHeaderCard`
- 抽离类型定义到 `types.ts`
- 抽离工具函数到 `utils.ts`
**拆分后文件结构**
```
CompanyOverview/
├── index.tsx # 主组件(组合层,约 60 行)
├── CompanyHeaderCard.tsx # 头部卡片组件(约 130 行)
├── BasicInfoTab.js # 基本信息 Tab懒加载版本约 994 行)
├── DeepAnalysisTab.js # 深度分析 Tab
├── NewsEventsTab.js # 新闻事件 Tab
├── types.ts # 类型定义(约 50 行)
├── utils.ts # 格式化工具(约 20 行)
└── hooks/
├── useBasicInfo.ts # 基本信息 Hook1 API
├── useShareholderData.ts # 股权结构 Hook4 APIs
├── useManagementData.ts # 管理团队 Hook1 API
├── useAnnouncementsData.ts # 公告数据 Hook1 API
├── useBranchesData.ts # 分支机构 Hook1 API
├── useDisclosureData.ts # 披露日程 Hook1 API
└── useCompanyOverviewData.ts # [已废弃] 原合并 Hook
```
**懒加载架构**2025-12-10 优化):
- `index.tsx` 只加载 `useBasicInfo`1 个 API用于头部卡片
- `BasicInfoTab.js` 使用 `isLazy` + 独立子组件实现懒加载
- 每个内层 Tab 使用独立 Hook点击时才加载数据
**Hooks 说明**
| Hook | API 数量 | 用途 |
|------|----------|------|
| `useBasicInfo` | 1 | 公司基本信息(头部卡片 + 工商信息 Tab |
| `useShareholderData` | 4 | 实控人、股权集中度、十大股东、十大流通股东 |
| `useManagementData` | 1 | 管理团队数据 |
| `useAnnouncementsData` | 1 | 公司公告列表 |
| `useBranchesData` | 1 | 分支机构列表 |
| `useDisclosureData` | 1 | 财报披露日程 |
**类型定义**`types.ts`
- `BasicInfo` - 公司基本信息
- `ActualControl` - 实际控制人
- `Concentration` - 股权集中度
- `Management` - 管理层信息
- `Shareholder` - 股东信息
- `Branch` - 分支机构
- `Announcement` - 公告信息
- `DisclosureSchedule` - 披露计划
- `CompanyOverviewData` - Hook 返回值类型
- `CompanyOverviewProps` - 组件 Props 类型
- `CompanyHeaderCardProps` - 头部卡片 Props 类型
**工具函数**`utils.ts`
- `formatRegisteredCapital(value)` - 格式化注册资本(万元/亿元)
- `formatDate(dateString)` - 格式化日期
**设计原则**
- **渐进式 TypeScript 迁移**:新拆分的文件使用 TypeScript旧文件暂保持 JS
- **关注点分离**:数据加载逻辑提取到 HookUI 逻辑保留在组件
- **类型复用**:统一的类型定义便于在多个文件间共享
- **懒加载优化**:减少首屏 API 请求,按需加载数据
### 2025-12-10 懒加载优化
**改动概述**
- 将 `useCompanyOverviewData`9 个 API拆分为 6 个独立 Hook
- `CompanyOverview/index.tsx` 只加载 `useBasicInfo`1 个 API
- `BasicInfoTab.js` 使用 5 个懒加载子组件,配合 `isLazy` 实现按需加载
- 页面初次加载从 **9 个 API** 减少到 **1 个 API**
**懒加载子组件**BasicInfoTab.js 内部):
| 子组件 | Hook | 功能 |
|--------|------|------|
| `ShareholderTabPanel` | `useShareholderData` | 股权结构4 APIs |
| `ManagementTabPanel` | `useManagementData` | 管理团队 |
| `AnnouncementsTabPanel` | `useAnnouncementsData` + `useDisclosureData` | 公告 + 披露日程 |
| `BranchesTabPanel` | `useBranchesData` | 分支机构 |
| `BusinessInfoTabPanel` | - | 工商信息(使用父组件传入的 basicInfo |
**实现原理**
- Chakra UI `Tabs` 的 `isLazy` 属性延迟渲染 TabPanel
- 每个 TabPanel 使用独立子组件,组件内调用 Hook
- 子组件只在首次激活时渲染,此时 Hook 才执行并发起 API 请求
| Tab 模块 | 中文名称 | 功能说明 |
|-------------------|------|----------------------------|
| CompanyOverview | 公司概览 | 公司基本信息、股权结构、管理层、公告等9个接口 |
| DeepAnalysis | 深度分析 | 公司深度研究报告、投资逻辑分析 |
| MarketDataView | 股票行情 | K线图、实时行情、技术指标 |
| FinancialPanorama | 财务全景 | 财务报表(资产负债表、利润表、现金流)、财务指标分析 |
| ForecastReport | 盈利预测 | 分析师预测、目标价、评级 |
| DynamicTracking | 动态跟踪 | 相关事件、新闻动态、投资日历 |
### 2025-12-10 MarketDataView TypeScript 拆分
**改动概述**
- `MarketDataView/index.js` 从 **2060 行** 拆分为 **12 个 TypeScript 文件**
- 采用 **TypeScript** 进行重构,提高类型安全性
- 提取 **1 个自定义 Hook**`useMarketData`
- 提取 **4 个子组件**ThemedCard、MarkdownRenderer、StockSummaryCard、AnalysisModal
- 抽离 API 服务到 `services/marketService.ts`
- 抽离图表配置到 `utils/chartOptions.ts`
**拆分后文件结构**
```
MarketDataView/
├── index.tsx # 主组件入口(~1049 行)
├── types.ts # 类型定义(~383 行)
├── constants.ts # 主题配置、常量(~49 行)
├── services/
│ └── marketService.ts # API 服务层(~173 行)
├── hooks/
│ └── useMarketData.ts # 数据获取 Hook~193 行)
├── utils/
│ ├── formatUtils.ts # 格式化工具函数(~175 行)
│ └── chartOptions.ts # ECharts 图表配置生成器(~698 行)
└── components/
├── index.ts # 组件导出(~8 行)
├── ThemedCard.tsx # 主题化卡片(~32 行)
├── MarkdownRenderer.tsx # Markdown 渲染(~65 行)
├── StockSummaryCard.tsx # 股票概览卡片(~133 行)
└── AnalysisModal.tsx # 涨幅分析模态框(~188 行)
```
**文件职责说明**
| 文件 | 行数 | 职责 |
|------|------|------|
| `index.tsx` | ~1049 | 主组件,包含 5 个 Tab 面板(交易数据、融资融券、大宗交易、龙虎榜、股权质押) |
| `types.ts` | ~383 | 所有 TypeScript 类型定义Theme、TradeDayData、MinuteData、FundingData 等) |
| `constants.ts` | ~49 | 主题配置light/dark、周期选项常量 |
| `marketService.ts` | ~173 | API 服务封装getMarketData、getMinuteData、getBigDealData 等) |
| `useMarketData.ts` | ~193 | 数据获取 Hook管理所有市场数据状态 |
| `formatUtils.ts` | ~175 | 数字/日期/涨跌幅格式化工具 |
| `chartOptions.ts` | ~698 | ECharts 配置生成器K线图、分钟图、融资融券图、质押图 |
| `ThemedCard.tsx` | ~32 | 主题化卡片容器组件 |
| `MarkdownRenderer.tsx` | ~65 | Markdown 内容渲染组件 |
| `StockSummaryCard.tsx` | ~133 | 股票概览卡片(价格、涨跌幅、成交量等) |
| `AnalysisModal.tsx` | ~188 | 涨幅分析详情模态框 |
**类型定义**`types.ts`
- `Theme` - 主题配置类型
- `TradeDayData` - 日线交易数据
- `MinuteData` - 分钟线数据
- `FundingDayData` - 融资融券数据
- `BigDealData` / `BigDealDayStats` - 大宗交易数据
- `UnusualData` / `UnusualDayData` - 龙虎榜数据
- `PledgeData` - 股权质押数据
- `RiseAnalysis` - 涨幅分析数据
- `MarketSummary` - 市场概览数据
- `VerificationReport` - 验证报告数据
- 各组件 Props 类型
**Hook 返回值**`useMarketData`
```typescript
{
loading: boolean;
summary: MarketSummary | null;
tradeData: TradeDayData[];
minuteData: MinuteData | null;
minuteLoading: boolean;
fundingData: FundingDayData[];
bigDealData: BigDealData | null;
unusualData: UnusualData | null;
pledgeData: PledgeData | null;
analysisMap: Record<number, RiseAnalysis>;
refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>;
}
```
**设计原则**
- **TypeScript 类型安全**:所有数据结构有完整类型定义
- **服务层分离**API 调用统一在 `marketService.ts` 中管理
- **图表配置抽离**:复杂的 ECharts 配置集中在 `chartOptions.ts`
- **组件复用**通用组件ThemedCard、MarkdownRenderer可在其他模块使用

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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