Compare commits

...

10 Commits

Author SHA1 Message Date
zdl
2cc16be585 docs(FinancialPanorama): 更新组件文档
- 更新目录结构说明
- 新增性能优化章节(memo、共享主题、组件提取等)
- 更新组件层级图
- 新增数据流图
- 新增懒加载策略说明

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:45:08 +08:00
zdl
11ca0e7a99 refactor(FinancialPanorama): 简化 useFinancialData Hook
- 移除未使用的 forecast 状态
- 移除未使用的 industryRank 状态
- 简化返回值类型定义

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:54 +08:00
zdl
ff951972ee refactor(FinancialPanorama): 优化主组件 Props 传递
- 使用 MetricChartModal 替代内联 Modal
- 简化 showMetricChart 回调
- componentProps 使用展开语法传递颜色常量
- 简化 useMemo 依赖数组
- 移除未使用的 imports

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:42 +08:00
zdl
41da6fa372 perf(FinancialPanorama): Tab 组件添加 memo 优化
- MetricsCategoryTab: 使用共享主题,主组件和 7 个子组件添加 memo
- BalanceSheetTab: 添加 memo
- IncomeStatementTab: 添加 memo
- CashflowTab: 添加 memo
- FinancialMetricsTab: 添加 memo
- 减少不必要的重渲染

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:32 +08:00
zdl
54cce55c29 perf(FinancialPanorama): 表格组件使用共享配置 + memo
- BalanceSheetTable: 使用共享主题,添加 memo
- IncomeStatementTable: 使用共享主题,添加 memo
- CashflowTable: 使用共享主题,添加 memo
- 移除内联主题定义,减少重复代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:26 +08:00
zdl
0e29f1aff4 refactor(FinancialPanorama): 提取 MetricChartModal 组件
- 从 index.tsx 提取独立的指标图表弹窗组件
- 使用 memo 包装优化性能
- 包含图表展示和同比/环比计算表格
- 减少主组件约 100 行代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:20 +08:00
zdl
7b58f83490 refactor(FinancialPanorama): 提取共享表格主题配置
- 新增 utils/tableTheme.ts 统一黑金主题配置
- BLACK_GOLD_TABLE_THEME: Ant Design ConfigProvider 主题
- getTableStyles(): CSS 样式工厂函数
- calculateYoY(): 同比计算共享函数
- 消除约 200 行重复代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:44:13 +08:00
zdl
22062a6556 perf(PledgePanel): 添加 useMemo 缓存图表配置
- 使用 useMemo 缓存 getPledgeDarkGoldOption 计算结果
- 避免每次渲染重新计算图表配置

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:23:47 +08:00
zdl
94854fee3e refactor(MarketDataView): 提取 DataRow 原子组件,样式统一
- 新增 shared/DataRow.tsx:通用数据行组件(支持 gold/orange/red/green 变体)
- 新增样式常量:financingRowStyle, securitiesRowStyle, buyRowStyle, sellRowStyle, dayCardStyle
- FundingPanel: 使用 useMemo 缓存图表配置和数据,使用 DataRow 替代重复结构
- BigDealPanel: 使用 dayCardStyle 替代内联样式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:20:46 +08:00
zdl
852d5fd188 refactor(ForecastReport): 架构优化与性能提升
阶段一 - 核心优化:
- 所有子组件添加 React.memo 防止不必要重渲染
- 图表组件统一使用 EChartsWrapper 替代 ReactECharts
- 提取 isForecastYear、IMPORTANT_METRICS 到 constants.ts
- DetailTable 样式提取为 DETAIL_TABLE_STYLES 常量

阶段二 - 架构优化:
- 新增 hooks/useForecastData.ts:数据获取 + Map 缓存 + AbortController
- 新增 services/forecastService.ts:API 封装层
- 新增 utils/chartFormatters.ts:图表格式化工具函数
- 主组件精简:79行 → 63行,添加错误处理和重试功能

优化效果:
- 消除 4 处 isForecastYear 重复定义
- 样式从每次渲染重建改为常量复用
- 添加请求缓存,避免频繁切换时重复请求

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 14:17:21 +08:00
35 changed files with 1116 additions and 947 deletions

View File

@@ -8,12 +8,12 @@
FinancialPanorama/ FinancialPanorama/
├── index.tsx # 主组件入口 ├── index.tsx # 主组件入口
├── types.ts # 类型定义 ├── types.ts # 类型定义
├── constants.ts # 常量配置 ├── constants.ts # 常量配置(指标定义、颜色等)
├── README.md # 本文档 ├── README.md # 本文档
├── hooks/ ├── hooks/
│ ├── index.ts # Hooks 导出 │ ├── index.ts # Hooks 导出
│ └── useFinancialData.ts # 财务数据获取 │ └── useFinancialData.ts # 财务数据获取 Hook
├── components/ # 子组件 ├── components/ # 子组件
│ ├── index.ts # 统一导出 │ ├── index.ts # 统一导出
@@ -21,55 +21,94 @@ FinancialPanorama/
│ ├── StockInfoHeader.tsx # 股票信息头部 │ ├── StockInfoHeader.tsx # 股票信息头部
│ ├── KeyMetricsOverview.tsx # 关键指标概览 │ ├── KeyMetricsOverview.tsx # 关键指标概览
│ ├── FinancialOverviewPanel.tsx # 财务概览面板 │ ├── FinancialOverviewPanel.tsx # 财务概览面板
│ ├── FinancialTable.tsx # 财务表格基础组件 │ ├── IncomeStatementTable.tsx # 利润表memo 优化)
│ ├── IncomeStatementTable.tsx # 利润表 │ ├── BalanceSheetTable.tsx # 资产负债表memo 优化)
│ ├── BalanceSheetTable.tsx # 资产负债表 │ ├── CashflowTable.tsx # 现金流量表memo 优化)
│ ├── CashflowTable.tsx # 现金流量表
│ ├── FinancialMetricsTable.tsx # 财务指标表 │ ├── FinancialMetricsTable.tsx # 财务指标表
│ ├── MetricChartModal.tsx # 指标图表弹窗(新增)
│ ├── MainBusinessAnalysis.tsx # 主营业务分析 │ ├── MainBusinessAnalysis.tsx # 主营业务分析
│ ├── IndustryRankingView.tsx # 行业排名视图 │ ├── IndustryRankingView.tsx # 行业排名视图
│ ├── StockComparison.tsx # 股票对比 │ ├── StockComparison.tsx # 股票对比
│ └── ComparisonAnalysis.tsx # 对比分析 │ └── ComparisonAnalysis.tsx # 对比分析
├── tabs/ # Tab 页面 ├── tabs/ # Tab 页面(全部 memo 优化)
│ ├── index.ts # 统一导出 │ ├── index.ts # 统一导出
│ ├── IncomeStatementTab.tsx # 利润表 Tab │ ├── MetricsCategoryTab.tsx # 指标分类 Tab含 7 个分类子组件)
│ ├── BalanceSheetTab.tsx # 资产负债表 Tab │ ├── BalanceSheetTab.tsx # 资产负债表 Tab
│ ├── IncomeStatementTab.tsx # 利润表 Tab
│ ├── CashflowTab.tsx # 现金流量表 Tab │ ├── CashflowTab.tsx # 现金流量表 Tab
── FinancialMetricsTab.tsx # 财务指标 Tab ── FinancialMetricsTab.tsx # 财务指标 Tab
│ └── MetricsCategoryTab.tsx # 指标分类 Tab
└── utils/ └── utils/
├── index.ts # 工具函数导出 ├── index.ts # 工具函数导出
├── calculations.ts # 计算函数 ├── calculations.ts # 计算函数
── chartOptions.ts # 图表配置 ── chartOptions.ts # 图表配置
└── tableTheme.ts # 共享表格主题(新增)
``` ```
## 功能模块 ## 功能模块
| 模块 | 说明 | | 模块 | 说明 |
|------|------| |------|------|
| 利润表 | 营收、利润、费用等损益数据 | | 盈利能力 | ROE、ROA、毛利率、净利率等 |
| 每股指标 | EPS、每股净资产、每股现金流等 |
| 成长能力 | 营收增速、利润增速等 |
| 运营效率 | 周转率、应收账款周转等 |
| 偿债能力 | 资产负债率、流动比率等 |
| 费用率 | 销售费用率、管理费用率等 |
| 现金流指标 | 现金流比率、自由现金流等 |
| 资产负债表 | 资产、负债、权益结构 | | 资产负债表 | 资产、负债、权益结构 |
| 利润表 | 营收、利润、费用等损益数据 |
| 现金流量表 | 经营、投资、筹资现金流 | | 现金流量表 | 经营、投资、筹资现金流 |
| 财务指标 | ROE、毛利率、周转率等 |
| 主营分析 | 业务构成、地区分布 | ## 性能优化2024-12
| 行业排名 | 同行业公司对比排名 |
### 1. React.memo 优化
- 所有 Tab 组件已使用 `memo` 包装,避免不必要的重渲染
- 表格组件BalanceSheetTable、IncomeStatementTable、CashflowTable已使用 `memo`
- MetricsCategoryTab 的 7 个子组件都已 memo 化
### 2. 共享主题配置
- 提取 `utils/tableTheme.ts` 共享 Ant Design 黑金主题
- `BLACK_GOLD_TABLE_THEME`: ConfigProvider 主题配置
- `getTableStyles()`: CSS 样式工厂函数
- `calculateYoY()`: 同比计算共享函数
### 3. 组件提取
- `MetricChartModal`: 从 index.tsx 提取的独立指标图表弹窗
- 减少主组件约 100 行代码
### 4. Props 传递优化
- componentProps 使用 `useMemo` 缓存
- 颜色常量直接展开,不在依赖数组中
- showMetricChart 使用 `useCallback` 优化
### 5. Hook 简化
- 移除未使用的 `forecast``industryRank` 状态
- 简化返回值类型定义
## 组件层级 ## 组件层级
``` ```
FinancialPanorama FinancialPanorama
├── PeriodSelector # 期数选择 ├── FinancialOverviewPanel # 财务概览(三列布局)
├── SubTabContainer # Tab 容器 ├── ComparisonAnalysis # 营收与利润趋势
│ ├── IncomeStatementTab # 利润表 ├── MainBusinessAnalysis # 主营业务分析
└── IncomeStatementTable ├── SubTabContainer # Tab 容器10 个 Tab
│ ├── ProfitabilityTab # 盈利能力
│ ├── PerShareTab # 每股指标
│ ├── GrowthTab # 成长能力
│ ├── OperationalTab # 运营效率
│ ├── SolvencyTab # 偿债能力
│ ├── ExpenseTab # 费用率
│ ├── CashflowMetricsTab # 现金流指标
│ ├── BalanceSheetTab # 资产负债表 │ ├── BalanceSheetTab # 资产负债表
│ │ └── BalanceSheetTable │ │ └── BalanceSheetTable
│ ├── CashflowTab # 现金流量 │ ├── IncomeStatementTab # 利润
│ │ └── CashflowTable │ │ └── IncomeStatementTable
│ └── FinancialMetricsTab # 财务指标 │ └── CashflowTab # 现金流量表
│ └── FinancialMetricsTable │ └── CashflowTable
└── MetricChartModal # 指标图表弹窗
``` ```
## 使用示例 ## 使用示例
@@ -79,3 +118,28 @@ import FinancialPanorama from '@views/Company/components/FinancialPanorama';
<FinancialPanorama stockCode="600000" /> <FinancialPanorama stockCode="600000" />
``` ```
## 数据流
```
stockCode (props)
useFinancialData (Hook)
├── stockInfo (基本信息)
├── financialMetrics (财务指标)
├── comparison (营收利润对比)
├── mainBusiness (主营业务)
├── balanceSheet (资产负债表)
├── incomeStatement (利润表)
└── cashflow (现金流量表)
componentProps (useMemo)
SubTabContainer → Tab 组件 → Table 组件
```
## 懒加载策略
- 初始加载stockInfo + financialMetrics + comparison + mainBusiness
- Tab 切换时按需加载balance/income/cashflow 数据
- 使用 `isLazy` 配置 SubTabContainer 实现懒加载

View File

@@ -2,7 +2,7 @@
* 资产负债表组件 - Ant Design 黑金主题 * 资产负债表组件 - Ant Design 黑金主题
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react'; import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd'; import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -17,79 +17,11 @@ import {
TOTAL_LIABILITIES_METRICS, TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS, EQUITY_METRICS,
} from '../constants'; } from '../constants';
import { getValueByPath } from '../utils'; import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { BalanceSheetTableProps, MetricConfig } from '../types'; import type { BalanceSheetTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置 const TABLE_CLASS_NAME = 'balance-sheet-table';
const BLACK_GOLD_THEME = { const tableStyles = getTableStyles(TABLE_CLASS_NAME);
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.balance-sheet-table .ant-table {
background: transparent !important;
}
.balance-sheet-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.balance-sheet-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.balance-sheet-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.balance-sheet-table .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.balance-sheet-table .positive-change {
color: #E53E3E;
}
.balance-sheet-table .negative-change {
color: #48BB78;
}
.balance-sheet-table .ant-table-placeholder {
background: transparent !important;
}
.balance-sheet-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型 // 表格行数据类型
interface TableRowData { interface TableRowData {
@@ -103,7 +35,7 @@ interface TableRowData {
[period: string]: unknown; [period: string]: unknown;
} }
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({ const BalanceSheetTableInner: React.FC<BalanceSheetTableProps> = ({
data, data,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
@@ -172,29 +104,13 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
return rows; return rows;
}, [data, displayData]); }, [data, displayData]);
// 计算同比变化 // 计算同比变化(使用共享函数)
const calculateYoY = ( const calcYoY = (
currentValue: number | undefined, currentValue: number | undefined,
currentPeriod: string, currentPeriod: string,
path: string path: string
): number | null => { ): number | null => {
if (currentValue === undefined || currentValue === null) return null; return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
}; };
// 构建列定义 // 构建列定义
@@ -236,7 +152,7 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
render: (value: number | undefined, record: TableRowData) => { render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null; if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path); const yoy = calcYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0); const formattedValue = formatUtils.formatLargeNumber(value, 0);
return ( return (
@@ -296,7 +212,7 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
return ( return (
<Box className="balance-sheet-table"> <Box className="balance-sheet-table">
<style>{tableStyles}</style> <style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}> <ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table <Table
columns={columns} columns={columns}
dataSource={tableData} dataSource={tableData}
@@ -323,4 +239,5 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
); );
}; };
export const BalanceSheetTable = memo(BalanceSheetTableInner);
export default BalanceSheetTable; export default BalanceSheetTable;

View File

@@ -2,82 +2,24 @@
* 现金流量表组件 - Ant Design 黑金主题 * 现金流量表组件 - Ant Design 黑金主题
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react'; import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd'; import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService'; import { formatUtils } from '@services/financialService';
import { CASHFLOW_METRICS } from '../constants'; import { CASHFLOW_METRICS } from '../constants';
import { getValueByPath } from '../utils'; import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { CashflowTableProps } from '../types'; import type { CashflowTableProps } from '../types';
// Ant Design 黑金主题配置 const TABLE_CLASS_NAME = 'cashflow-table';
const BLACK_GOLD_THEME = { const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
token: { .${TABLE_CLASS_NAME} .positive-value {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.cashflow-table .ant-table {
background: transparent !important;
}
.cashflow-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.cashflow-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.cashflow-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.cashflow-table .ant-table-cell-fix-left,
.cashflow-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.cashflow-table .positive-value {
color: #E53E3E; color: #E53E3E;
} }
.cashflow-table .negative-value { .${TABLE_CLASS_NAME} .negative-value {
color: #48BB78; color: #48BB78;
} }
.cashflow-table .positive-change {
color: #E53E3E;
}
.cashflow-table .negative-change {
color: #48BB78;
}
.cashflow-table .ant-table-placeholder {
background: transparent !important;
}
.cashflow-table .ant-empty-description {
color: #A0AEC0;
}
`; `;
// 核心指标 // 核心指标
@@ -92,7 +34,7 @@ interface TableRowData {
[period: string]: unknown; [period: string]: unknown;
} }
export const CashflowTable: React.FC<CashflowTableProps> = ({ const CashflowTableInner: React.FC<CashflowTableProps> = ({
data, data,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
@@ -131,29 +73,13 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
}); });
}, [data, displayData]); }, [data, displayData]);
// 计算同比变化 // 计算同比变化(使用共享函数)
const calculateYoY = ( const calcYoY = (
currentValue: number | undefined, currentValue: number | undefined,
currentPeriod: string, currentPeriod: string,
path: string path: string
): number | null => { ): number | null => {
if (currentValue === undefined || currentValue === null) return null; return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
}; };
// 构建列定义 // 构建列定义
@@ -188,7 +114,7 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
width: 110, width: 110,
align: 'right' as const, align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => { render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path); const yoy = calcYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 1); const formattedValue = formatUtils.formatLargeNumber(value, 1);
const isNegative = value !== undefined && value < 0; const isNegative = value !== undefined && value < 0;
@@ -246,7 +172,7 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
return ( return (
<Box className="cashflow-table"> <Box className="cashflow-table">
<style>{tableStyles}</style> <style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}> <ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table <Table
columns={columns} columns={columns}
dataSource={tableData} dataSource={tableData}
@@ -266,4 +192,5 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
); );
}; };
export const CashflowTable = memo(CashflowTableInner);
export default CashflowTable; export default CashflowTable;

View File

@@ -2,92 +2,25 @@
* 利润表组件 - Ant Design 黑金主题 * 利润表组件 - Ant Design 黑金主题
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react'; import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd'; import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService'; import { formatUtils } from '@services/financialService';
import { INCOME_STATEMENT_SECTIONS } from '../constants'; import { INCOME_STATEMENT_SECTIONS } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils'; import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { IncomeStatementTableProps, MetricConfig } from '../types'; import type { IncomeStatementTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置 const TABLE_CLASS_NAME = 'income-statement-table';
const BLACK_GOLD_THEME = { const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
token: { .${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.income-statement-table .ant-table {
background: transparent !important;
}
.income-statement-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.income-statement-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.income-statement-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.income-statement-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
background: rgba(212, 175, 55, 0.1) !important; background: rgba(212, 175, 55, 0.1) !important;
font-weight: 500; font-weight: 500;
} }
.income-statement-table .ant-table-tbody > tr.section-header > td { .${TABLE_CLASS_NAME} .negative-value {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.income-statement-table .ant-table-cell-fix-left,
.income-statement-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.income-statement-table .positive-change {
color: #E53E3E; color: #E53E3E;
} }
.income-statement-table .negative-change {
color: #48BB78;
}
.income-statement-table .negative-value {
color: #E53E3E;
}
.income-statement-table .ant-table-placeholder {
background: transparent !important;
}
.income-statement-table .ant-empty-description {
color: #A0AEC0;
}
`; `;
// 表格行数据类型 // 表格行数据类型
@@ -103,7 +36,7 @@ interface TableRowData {
[period: string]: unknown; [period: string]: unknown;
} }
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({ const IncomeStatementTableInner: React.FC<IncomeStatementTableProps> = ({
data, data,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
@@ -160,29 +93,13 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
return rows; return rows;
}, [data, displayData]); }, [data, displayData]);
// 计算同比变化 // 计算同比变化(使用共享函数)
const calculateYoY = ( const calcYoY = (
currentValue: number | undefined, currentValue: number | undefined,
currentPeriod: string, currentPeriod: string,
path: string path: string
): number | null => { ): number | null => {
if (currentValue === undefined || currentValue === null) return null; return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
}; };
// 构建列定义 // 构建列定义
@@ -224,7 +141,7 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
render: (value: number | undefined, record: TableRowData) => { render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null; if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, record.path); const yoy = calcYoY(value, item.period, record.path);
const isEPS = record.key.includes('eps'); const isEPS = record.key.includes('eps');
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0); const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
const isNegative = value !== undefined && value < 0; const isNegative = value !== undefined && value < 0;
@@ -295,7 +212,7 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
return ( return (
<Box className="income-statement-table"> <Box className="income-statement-table">
<style>{tableStyles}</style> <style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}> <ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table <Table
columns={columns} columns={columns}
dataSource={tableData} dataSource={tableData}
@@ -323,4 +240,5 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
); );
}; };
export const IncomeStatementTable = memo(IncomeStatementTableInner);
export default IncomeStatementTable; export default IncomeStatementTable;

View File

@@ -0,0 +1,173 @@
/**
* 指标图表弹窗组件
*
* 显示指标的趋势图表和详细数据表格
*/
import React, { useMemo, memo } from 'react';
import {
Box,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Divider,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getMetricChartOption } from '../utils';
import { COLORS } from '../constants';
/** 图表数据项 */
interface ChartDataItem {
period: string;
date: string;
value: number;
}
/** 组件 Props */
export interface MetricChartModalProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 指标名称 */
metricName: string;
/** 原始数据 */
data: Array<{ period: string; [key: string]: unknown }>;
/** 数据路径 */
dataPath: string;
}
/**
* 从数据路径获取值
*/
const getValueByPath = (item: Record<string, unknown>, path: string): number | undefined => {
return path.split('.').reduce((obj: unknown, key: string) => {
if (obj && typeof obj === 'object') {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}, item) as number | undefined;
};
/**
* 指标图表弹窗
*/
const MetricChartModalInner: React.FC<MetricChartModalProps> = ({
isOpen,
onClose,
metricName,
data,
dataPath,
}) => {
const { positiveColor, negativeColor } = COLORS;
// 计算图表数据
const chartData = useMemo((): ChartDataItem[] => {
if (!data || data.length === 0) return [];
return data
.map((item) => {
const value = getValueByPath(item as Record<string, unknown>, dataPath);
return {
period: formatUtils.getReportType(item.period),
date: item.period,
value: value ?? 0,
};
})
.reverse();
}, [data, dataPath]);
// 图表配置
const chartOption = useMemo(() => {
return getMetricChartOption(metricName, chartData);
}, [metricName, chartData]);
// 计算同比环比
const tableRows = useMemo(() => {
return chartData.map((item, idx) => {
// 计算环比 (QoQ)
const qoq =
idx > 0 && chartData[idx - 1].value !== 0
? ((item.value - chartData[idx - 1].value) / Math.abs(chartData[idx - 1].value)) * 100
: null;
// 计算同比 (YoY)
const currentDate = new Date(item.date);
const lastYearItem = chartData.find((d) => {
const date = new Date(d.date);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
const yoy =
lastYearItem && lastYearItem.value !== 0
? ((item.value - lastYearItem.value) / Math.abs(lastYearItem.value)) * 100
: null;
return { ...item, yoy, qoq };
});
}, [chartData]);
// 获取变化颜色
const getChangeColor = (value: number | null) => {
if (value === null) return 'gray.500';
return value > 0 ? positiveColor : value < 0 ? negativeColor : 'gray.500';
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="900px">
<ModalHeader>{metricName} - </ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<Box>
<ReactECharts option={chartOption} style={{ height: '400px', width: '100%' }} />
<Divider my={4} />
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{tableRows.map((item, idx) => (
<Tr key={idx}>
<Td>{item.period}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.value)}</Td>
<Td isNumeric color={getChangeColor(item.yoy)}>
{item.yoy !== null ? `${item.yoy.toFixed(2)}%` : '-'}
</Td>
<Td isNumeric color={getChangeColor(item.qoq)}>
{item.qoq !== null ? `${item.qoq.toFixed(2)}%` : '-'}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</ModalBody>
</ModalContent>
</Modal>
);
};
export const MetricChartModal = memo(MetricChartModalInner);
export default MetricChartModal;

View File

@@ -15,3 +15,5 @@ export { MainBusinessAnalysis } from './MainBusinessAnalysis';
export { IndustryRankingView } from './IndustryRankingView'; export { IndustryRankingView } from './IndustryRankingView';
export { StockComparison } from './StockComparison'; export { StockComparison } from './StockComparison';
export { ComparisonAnalysis } from './ComparisonAnalysis'; export { ComparisonAnalysis } from './ComparisonAnalysis';
export { MetricChartModal } from './MetricChartModal';
export type { MetricChartModalProps } from './MetricChartModal';

View File

@@ -15,8 +15,6 @@ import type {
CashflowData, CashflowData,
FinancialMetricsData, FinancialMetricsData,
MainBusinessData, MainBusinessData,
ForecastData,
IndustryRankData,
ComparisonData, ComparisonData,
} from '../types'; } from '../types';
@@ -51,8 +49,6 @@ interface UseFinancialDataReturn {
cashflow: CashflowData[]; cashflow: CashflowData[];
financialMetrics: FinancialMetricsData[]; financialMetrics: FinancialMetricsData[];
mainBusiness: MainBusinessData | null; mainBusiness: MainBusinessData | null;
forecast: ForecastData | null;
industryRank: IndustryRankData[];
comparison: ComparisonData[]; comparison: ComparisonData[];
// 加载状态 // 加载状态
@@ -100,8 +96,6 @@ export const useFinancialData = (
const [cashflow, setCashflow] = useState<CashflowData[]>([]); const [cashflow, setCashflow] = useState<CashflowData[]>([]);
const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]); const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]);
const [mainBusiness, setMainBusiness] = useState<MainBusinessData | null>(null); const [mainBusiness, setMainBusiness] = useState<MainBusinessData | null>(null);
const [forecast, setForecast] = useState<ForecastData | null>(null);
const [industryRank, setIndustryRank] = useState<IndustryRankData[]>([]);
const [comparison, setComparison] = useState<ComparisonData[]>([]); const [comparison, setComparison] = useState<ComparisonData[]>([]);
const toast = useToast(); const toast = useToast();
@@ -304,8 +298,6 @@ export const useFinancialData = (
cashflow, cashflow,
financialMetrics, financialMetrics,
mainBusiness, mainBusiness,
forecast,
industryRank,
comparison, comparison,
// 加载状态 // 加载状态

View File

@@ -3,7 +3,7 @@
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航 * 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
*/ */
import React, { useState, useMemo, useCallback, ReactNode } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { import {
Box, Box,
Container, Container,
@@ -13,21 +13,7 @@ import {
Text, Text,
Alert, Alert,
AlertIcon, AlertIcon,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure, useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Divider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import {
BarChart3, BarChart3,
@@ -41,8 +27,6 @@ import {
Receipt, Receipt,
Banknote, Banknote,
} from 'lucide-react'; } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
// 通用组件 // 通用组件
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer'; import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
@@ -51,8 +35,14 @@ import LoadingState from '../LoadingState';
// 内部模块导入 // 内部模块导入
import { useFinancialData, type DataTypeKey } from './hooks'; import { useFinancialData, type DataTypeKey } from './hooks';
import { COLORS } from './constants'; import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils'; import { calculateYoYChange, getCellBackground } from './utils';
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components'; import {
PeriodSelector,
FinancialOverviewPanel,
MainBusinessAnalysis,
ComparisonAnalysis,
MetricChartModal,
} from './components';
import { import {
BalanceSheetTab, BalanceSheetTab,
IncomeStatementTab, IncomeStatementTab,
@@ -117,111 +107,22 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
// UI 状态 // UI 状态
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState<ReactNode>(null); const [modalProps, setModalProps] = useState<{
metricName: string;
data: Array<{ period: string; [key: string]: unknown }>;
dataPath: string;
}>({ metricName: '', data: [], dataPath: '' });
// 颜色配置 // 点击指标行显示图表
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
// 点击指标行显示图表(使用 useCallback 避免不必要的重渲染)
const showMetricChart = useCallback(( const showMetricChart = useCallback((
metricName: string, metricName: string,
_metricKey: string, _metricKey: string,
data: Array<{ period: string; [key: string]: unknown }>, data: Array<{ period: string; [key: string]: unknown }>,
dataPath: string dataPath: string
) => { ) => {
const chartData = data setModalProps({ metricName, data, dataPath });
.map((item) => {
const value = dataPath.split('.').reduce((obj: unknown, key: string) => {
if (obj && typeof obj === 'object') {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}, item) as number | undefined;
return {
period: formatUtils.getReportType(item.period),
date: item.period,
value: value ?? 0,
};
})
.reverse();
const option = getMetricChartOption(metricName, chartData);
setModalContent(
<Box>
<ReactECharts option={option} style={{ height: '400px', width: '100%' }} />
<Divider my={4} />
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{chartData.map((item, idx) => {
// 计算环比
const qoq =
idx > 0
? ((item.value - chartData[idx - 1].value) /
Math.abs(chartData[idx - 1].value)) *
100
: null;
// 计算同比
const currentDate = new Date(item.date);
const lastYearItem = chartData.find((d) => {
const date = new Date(d.date);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
const yoy = lastYearItem
? ((item.value - lastYearItem.value) / Math.abs(lastYearItem.value)) * 100
: null;
return (
<Tr key={idx}>
<Td>{item.period}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.value)}</Td>
<Td
isNumeric
color={
yoy !== null && yoy > 0
? positiveColor
: yoy !== null && yoy < 0
? negativeColor
: 'gray.500'
}
>
{yoy !== null ? `${yoy.toFixed(2)}%` : '-'}
</Td>
<Td
isNumeric
color={
qoq !== null && qoq > 0
? positiveColor
: qoq !== null && qoq < 0
? negativeColor
: 'gray.500'
}
>
{qoq !== null ? `${qoq.toFixed(2)}%` : '-'}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
);
onOpen(); onOpen();
}, [onOpen, positiveColor, negativeColor]); }, [onOpen]);
// Tab 配置 - 财务指标分类 + 三大财务报表 // Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo( const tabConfigs: SubTabConfig[] = useMemo(
@@ -242,7 +143,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
[] []
); );
// 传递给 Tab 组件的 props // 传递给 Tab 组件的 props(颜色使用常量,不需要在依赖数组中)
const componentProps = useMemo( const componentProps = useMemo(
() => ({ () => ({
// 数据 // 数据
@@ -257,11 +158,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
getCellBackground, getCellBackground,
// 颜色配置 // 颜色配置(使用常量)
positiveColor, ...COLORS,
negativeColor,
bgColor,
hoverBg,
}), }),
[ [
balanceSheet, balanceSheet,
@@ -271,10 +169,6 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
loading, loading,
loadingTab, loadingTab,
showMetricChart, showMetricChart,
positiveColor,
negativeColor,
bgColor,
hoverBg,
] ]
); );
@@ -335,15 +229,14 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
</Alert> </Alert>
)} )}
{/* 弹出模态框 */} {/* 指标图表弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl"> <MetricChartModal
<ModalOverlay /> isOpen={isOpen}
<ModalContent maxW="900px"> onClose={onClose}
<ModalHeader></ModalHeader> metricName={modalProps.metricName}
<ModalCloseButton /> data={modalProps.data}
<ModalBody pb={6}>{modalContent}</ModalBody> dataPath={modalProps.dataPath}
</ModalContent> />
</Modal>
</VStack> </VStack>
</Container> </Container>
); );

View File

@@ -2,7 +2,7 @@
* 资产负债表 Tab * 资产负债表 Tab
*/ */
import React from 'react'; import React, { memo } from 'react';
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components'; import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types'; import type { BalanceSheetData } from '../types';
@@ -19,7 +19,7 @@ export interface BalanceSheetTabProps {
hoverBg: string; hoverBg: string;
} }
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({ const BalanceSheetTabInner: React.FC<BalanceSheetTabProps> = ({
balanceSheet, balanceSheet,
loading, loading,
showMetricChart, showMetricChart,
@@ -72,4 +72,5 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
); );
}; };
const BalanceSheetTab = memo(BalanceSheetTabInner);
export default BalanceSheetTab; export default BalanceSheetTab;

View File

@@ -2,7 +2,7 @@
* 现金流量表 Tab * 现金流量表 Tab
*/ */
import React from 'react'; import React, { memo } from 'react';
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { CashflowTable } from '../components'; import { CashflowTable } from '../components';
import type { CashflowData } from '../types'; import type { CashflowData } from '../types';
@@ -19,7 +19,7 @@ export interface CashflowTabProps {
hoverBg: string; hoverBg: string;
} }
const CashflowTab: React.FC<CashflowTabProps> = ({ const CashflowTabInner: React.FC<CashflowTabProps> = ({
cashflow, cashflow,
loading, loading,
showMetricChart, showMetricChart,
@@ -72,4 +72,5 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
); );
}; };
const CashflowTab = memo(CashflowTabInner);
export default CashflowTab; export default CashflowTab;

View File

@@ -2,7 +2,7 @@
* 财务指标 Tab * 财务指标 Tab
*/ */
import React from 'react'; import React, { memo } from 'react';
import { Spinner, Center } from '@chakra-ui/react'; import { Spinner, Center } from '@chakra-ui/react';
import { FinancialMetricsTable } from '../components'; import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types'; import type { FinancialMetricsData } from '../types';
@@ -19,7 +19,7 @@ export interface FinancialMetricsTabProps {
hoverBg: string; hoverBg: string;
} }
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({ const FinancialMetricsTabInner: React.FC<FinancialMetricsTabProps> = ({
financialMetrics, financialMetrics,
loading, loading,
showMetricChart, showMetricChart,
@@ -54,4 +54,5 @@ const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
); );
}; };
const FinancialMetricsTab = memo(FinancialMetricsTabInner);
export default FinancialMetricsTab; export default FinancialMetricsTab;

View File

@@ -2,7 +2,7 @@
* 利润表 Tab * 利润表 Tab
*/ */
import React from 'react'; import React, { memo } from 'react';
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components'; import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types'; import type { IncomeStatementData } from '../types';
@@ -19,7 +19,7 @@ export interface IncomeStatementTabProps {
hoverBg: string; hoverBg: string;
} }
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({ const IncomeStatementTabInner: React.FC<IncomeStatementTabProps> = ({
incomeStatement, incomeStatement,
loading, loading,
showMetricChart, showMetricChart,
@@ -72,4 +72,5 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
); );
}; };
const IncomeStatementTab = memo(IncomeStatementTabInner);
export default IncomeStatementTab; export default IncomeStatementTab;

View File

@@ -3,84 +3,26 @@
* 接受 categoryKey 显示单个分类的指标表格 * 接受 categoryKey 显示单个分类的指标表格
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Spinner, Center } from '@chakra-ui/react'; import { Box, Text, HStack, Badge as ChakraBadge, Spinner, Center } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd'; import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService'; import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants'; import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils'; import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { FinancialMetricsData } from '../types'; import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES; type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置 const TABLE_CLASS_NAME = 'metrics-category-table';
const BLACK_GOLD_THEME = { const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
token: { .${TABLE_CLASS_NAME} .positive-value {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.metrics-category-table .ant-table {
background: transparent !important;
}
.metrics-category-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.metrics-category-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.metrics-category-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.metrics-category-table .ant-table-cell-fix-left,
.metrics-category-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.metrics-category-table .positive-change {
color: #E53E3E; color: #E53E3E;
} }
.metrics-category-table .negative-change { .${TABLE_CLASS_NAME} .negative-value {
color: #48BB78; color: #48BB78;
} }
.metrics-category-table .positive-value {
color: #E53E3E;
}
.metrics-category-table .negative-value {
color: #48BB78;
}
.metrics-category-table .ant-table-placeholder {
background: transparent !important;
}
.metrics-category-table .ant-empty-description {
color: #A0AEC0;
}
`; `;
export interface MetricsCategoryTabProps { export interface MetricsCategoryTabProps {
@@ -105,7 +47,7 @@ interface TableRowData {
[period: string]: unknown; [period: string]: unknown;
} }
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({ const MetricsCategoryTabInner: React.FC<MetricsCategoryTabProps> = ({
categoryKey, categoryKey,
financialMetrics, financialMetrics,
loading, loading,
@@ -162,29 +104,13 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
}); });
}, [financialMetrics, displayData, category]); }, [financialMetrics, displayData, category]);
// 计算同比变化 // 计算同比变化(使用共享函数)
const calculateYoY = ( const calcYoY = (
currentValue: number | undefined, currentValue: number | undefined,
currentPeriod: string, currentPeriod: string,
path: string path: string
): number | null => { ): number | null => {
if (currentValue === undefined || currentValue === null) return null; return calculateYoY(financialMetrics, currentValue, currentPeriod, path, getValueByPath);
const currentDate = new Date(currentPeriod);
const lastYearPeriod = financialMetrics.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
}; };
// 构建列定义 // 构建列定义
@@ -219,7 +145,7 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
width: 100, width: 100,
align: 'right' as const, align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => { render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path); const yoy = calcYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key); const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色) // 对于负向指标,增加是坏事(绿色),减少是好事(红色)
@@ -287,7 +213,7 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
<Box> <Box>
<Box className="metrics-category-table"> <Box className="metrics-category-table">
<style>{tableStyles}</style> <style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}> <ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table <Table
columns={columns} columns={columns}
dataSource={tableData} dataSource={tableData}
@@ -309,33 +235,35 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
); );
}; };
// 为每个分类创建预配置的组件 const MetricsCategoryTab = memo(MetricsCategoryTabInner);
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
// 为每个分类创建预配置的组件(使用 memo
export const ProfitabilityTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} /> <MetricsCategoryTab categoryKey="profitability" {...props} />
); ));
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const PerShareTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} /> <MetricsCategoryTab categoryKey="perShare" {...props} />
); ));
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const GrowthTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="growth" {...props} /> <MetricsCategoryTab categoryKey="growth" {...props} />
); ));
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const OperationalTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="operational" {...props} /> <MetricsCategoryTab categoryKey="operational" {...props} />
); ));
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const SolvencyTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} /> <MetricsCategoryTab categoryKey="solvency" {...props} />
); ));
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const ExpenseTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="expense" {...props} /> <MetricsCategoryTab categoryKey="expense" {...props} />
); ));
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => ( export const CashflowMetricsTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} /> <MetricsCategoryTab categoryKey="cashflow" {...props} />
); ));
export default MetricsCategoryTab; export default MetricsCategoryTab;

View File

@@ -15,3 +15,9 @@ export {
getMainBusinessPieOption, getMainBusinessPieOption,
getCompareBarChartOption, getCompareBarChartOption,
} from './chartOptions'; } from './chartOptions';
export {
BLACK_GOLD_TABLE_THEME,
getTableStyles,
calculateYoY,
} from './tableTheme';

View File

@@ -0,0 +1,115 @@
/**
* 财务表格共享主题配置
*
* 用于统一 BalanceSheetTable、IncomeStatementTable、CashflowTable 等表格组件的主题样式
*/
import type { ThemeConfig } from 'antd';
/** Ant Design 黑金主题配置 */
export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
/**
* 生成表格样式 CSS
* @param className 表格容器的 className
*/
export const getTableStyles = (className: string): string => `
.${className} .ant-table {
background: transparent !important;
}
.${className} .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.${className} .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.${className} .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.${className} .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.${className} .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.${className} .ant-table-cell-fix-left,
.${className} .ant-table-cell-fix-right {
background: #1A202C !important;
}
.${className} .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.${className} .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.${className} .positive-change {
color: #E53E3E;
}
.${className} .negative-change {
color: #48BB78;
}
.${className} .ant-table-placeholder {
background: transparent !important;
}
.${className} .ant-empty-description {
color: #A0AEC0;
}
`;
/**
* 计算同比变化YoY
* @param data 数据数组
* @param currentValue 当前值
* @param currentPeriod 当前期间
* @param path 数据路径
* @param getValueByPath 获取路径值的函数
*/
export const calculateYoY = <T extends { period: string }>(
data: T[],
currentValue: number | undefined,
currentPeriod: string,
path: string,
getValueByPath: (item: T, path: string) => number | undefined
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};

View File

@@ -2,7 +2,7 @@
* 通用图表卡片组件 - 黑金主题 * 通用图表卡片组件 - 黑金主题
*/ */
import React from 'react'; import React, { memo } from 'react';
import { Box, Heading } from '@chakra-ui/react'; import { Box, Heading } from '@chakra-ui/react';
import { THEME } from '../constants'; import { THEME } from '../constants';
import type { ChartCardProps } from '../types'; import type { ChartCardProps } from '../types';
@@ -34,4 +34,4 @@ const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
); );
}; };
export default ChartCard; export default memo(ChartCard);

View File

@@ -3,17 +3,12 @@
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分 * 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, useCallback, memo } from 'react';
import { Box, Text } from '@chakra-ui/react'; import { Box, Text } from '@chakra-ui/react';
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd'; import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { DetailTableProps, DetailTableRow } from '../types'; import type { DetailTableProps, DetailTableRow } from '../types';
import { isForecastYear, IMPORTANT_METRICS, DETAIL_TABLE_STYLES } from '../constants';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
// 重要指标(需要高亮的行)
const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
// Ant Design 黑金主题配置 // Ant Design 黑金主题配置
const BLACK_GOLD_THEME = { const BLACK_GOLD_THEME = {
@@ -40,80 +35,6 @@ const BLACK_GOLD_THEME = {
}, },
}; };
// 表格样式 - 斑马纹、等宽字体、预测列区分
const tableStyles = `
/* 固定列背景 */
.forecast-detail-table .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.98) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
background: #242d3d !important;
}
/* 指标标签样式 */
.forecast-detail-table .metric-tag {
background: rgba(212, 175, 55, 0.15);
border-color: rgba(212, 175, 55, 0.3);
color: #D4AF37;
font-weight: 500;
}
/* 重要指标行高亮 */
.forecast-detail-table .important-row {
background: rgba(212, 175, 55, 0.06) !important;
}
.forecast-detail-table .important-row .metric-tag {
background: rgba(212, 175, 55, 0.25);
color: #FFD700;
font-weight: 600;
}
/* 斑马纹 - 奇数行 */
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td {
background: rgba(255, 255, 255, 0.02);
}
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
/* 等宽字体 - 数值列 */
.forecast-detail-table .data-cell {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
/* 预测列样式 */
.forecast-detail-table .forecast-col {
background: rgba(212, 175, 55, 0.04) !important;
font-style: italic;
}
.forecast-detail-table .ant-table-thead .forecast-col {
color: #FFD700 !important;
font-weight: 600;
}
/* 负数红色显示 */
.forecast-detail-table .negative-value {
color: #FC8181;
}
/* 正增长绿色 */
.forecast-detail-table .positive-growth {
color: #68D391;
}
/* 表头预测/历史分隔线 */
.forecast-detail-table .forecast-divider {
border-left: 2px solid rgba(212, 175, 55, 0.5) !important;
}
`;
// 表格行数据类型 - 扩展索引签名以支持 boolean // 表格行数据类型 - 扩展索引签名以支持 boolean
type TableRowData = { type TableRowData = {
key: string; key: string;
@@ -193,14 +114,14 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
}); });
}, [rows]); }, [rows]);
// 行类名 // 行类名 - 使用 useCallback 避免不必要的重渲染
const rowClassName = (record: TableRowData) => { const rowClassName = useCallback((record: TableRowData) => {
return record.isImportant ? 'important-row' : ''; return record.isImportant ? 'important-row' : '';
}; }, []);
return ( return (
<Box className="forecast-detail-table"> <Box className="forecast-detail-table">
<style>{tableStyles}</style> <style>{DETAIL_TABLE_STYLES}</style>
<Text fontSize="md" fontWeight="bold" color="#D4AF37" mb={3}> <Text fontSize="md" fontWeight="bold" color="#D4AF37" mb={3}>
</Text> </Text>
@@ -219,4 +140,4 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
); );
}; };
export default DetailTable; export default memo(DetailTable);

View File

@@ -3,15 +3,12 @@
* 优化:添加行业平均参考线、预测区分、置信区间 * 优化:添加行业平均参考线、预测区分、置信区间
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import ReactECharts from 'echarts-for-react'; import EChartsWrapper from '../../EChartsWrapper';
import ChartCard from './ChartCard'; import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME, isForecastYear } from '../constants';
import type { EpsChartProps } from '../types'; import type { EpsChartProps } from '../types';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
const EpsChart: React.FC<EpsChartProps> = ({ data }) => { const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
// 计算行业平均EPS模拟数据实际应从API获取 // 计算行业平均EPS模拟数据实际应从API获取
const industryAvgEps = useMemo(() => { const industryAvgEps = useMemo(() => {
@@ -124,9 +121,9 @@ const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
return ( return (
<ChartCard title="EPS 趋势"> <ChartCard title="EPS 趋势">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} /> <EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard> </ChartCard>
); );
}; };
export default EpsChart; export default memo(EpsChart);

View File

@@ -3,10 +3,10 @@
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化 * 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import ReactECharts from 'echarts-for-react'; import EChartsWrapper from '../../EChartsWrapper';
import ChartCard from './ChartCard'; import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME, isForecastYear } from '../constants';
import type { IncomeProfitTrend, GrowthBars } from '../types'; import type { IncomeProfitTrend, GrowthBars } from '../types';
interface IncomeProfitGrowthChartProps { interface IncomeProfitGrowthChartProps {
@@ -14,9 +14,6 @@ interface IncomeProfitGrowthChartProps {
growthData: GrowthBars; growthData: GrowthBars;
} }
// 判断是否为预测年份(包含 E 后缀)
const isForecastYear = (year: string) => year.includes('E');
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
incomeProfitData, incomeProfitData,
growthData, growthData,
@@ -196,9 +193,9 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
return ( return (
<ChartCard title="营收与利润趋势 · 增长率"> <ChartCard title="营收与利润趋势 · 增长率">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} /> <EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard> </ChartCard>
); );
}; };
export default IncomeProfitGrowthChart; export default memo(IncomeProfitGrowthChart);

View File

@@ -3,15 +3,12 @@
* 优化配色区分度、线条样式、Y轴颜色对应、预测区分 * 优化配色区分度、线条样式、Y轴颜色对应、预测区分
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, memo } from 'react';
import ReactECharts from 'echarts-for-react'; import EChartsWrapper from '../../EChartsWrapper';
import ChartCard from './ChartCard'; import ChartCard from './ChartCard';
import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME } from '../constants'; import { CHART_COLORS, BASE_CHART_CONFIG, CHART_HEIGHT, THEME, isForecastYear } from '../constants';
import type { PePegChartProps } from '../types'; import type { PePegChartProps } from '../types';
// 判断是否为预测年份
const isForecastYear = (year: string) => year.includes('E');
const PePegChart: React.FC<PePegChartProps> = ({ data }) => { const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
// 找出预测数据起始索引 // 找出预测数据起始索引
const forecastStartIndex = useMemo(() => { const forecastStartIndex = useMemo(() => {
@@ -145,9 +142,9 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
return ( return (
<ChartCard title="PE 与 PEG 分析"> <ChartCard title="PE 与 PEG 分析">
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} /> <EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
</ChartCard> </ChartCard>
); );
}; };
export default PePegChart; export default memo(PePegChart);

View File

@@ -92,3 +92,98 @@ export const BASE_CHART_CONFIG = {
// 图表高度 // 图表高度
export const CHART_HEIGHT = 280; export const CHART_HEIGHT = 280;
// ============================================
// 工具函数
// ============================================
/**
* 判断是否为预测年份(包含 E 后缀)
* @param year 年份字符串,如 "2024E"
*/
export const isForecastYear = (year: string): boolean => year.includes('E');
/**
* 重要指标列表(用于表格行高亮)
*/
export const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
// ============================================
// DetailTable 样式
// ============================================
/**
* 详细数据表格样式 - 斑马纹、等宽字体、预测列区分
*/
export const DETAIL_TABLE_STYLES = `
/* 固定列背景 */
.forecast-detail-table .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-left,
.forecast-detail-table .ant-table-thead .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.98) !important;
}
.forecast-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left {
background: #242d3d !important;
}
/* 指标标签样式 */
.forecast-detail-table .metric-tag {
background: rgba(212, 175, 55, 0.15);
border-color: rgba(212, 175, 55, 0.3);
color: #D4AF37;
font-weight: 500;
}
/* 重要指标行高亮 */
.forecast-detail-table .important-row {
background: rgba(212, 175, 55, 0.06) !important;
}
.forecast-detail-table .important-row .metric-tag {
background: rgba(212, 175, 55, 0.25);
color: #FFD700;
font-weight: 600;
}
/* 斑马纹 - 奇数行 */
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd) > td {
background: rgba(255, 255, 255, 0.02);
}
.forecast-detail-table .ant-table-tbody > tr:nth-child(odd):hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
/* 等宽字体 - 数值列 */
.forecast-detail-table .data-cell {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
/* 预测列样式 */
.forecast-detail-table .forecast-col {
background: rgba(212, 175, 55, 0.04) !important;
font-style: italic;
}
.forecast-detail-table .ant-table-thead .forecast-col {
color: #FFD700 !important;
font-weight: 600;
}
/* 负数红色显示 */
.forecast-detail-table .negative-value {
color: #FC8181;
}
/* 正增长绿色 */
.forecast-detail-table .positive-growth {
color: #68D391;
}
/* 表头预测/历史分隔线 */
.forecast-detail-table .forecast-divider {
border-left: 2px solid rgba(212, 175, 55, 0.5) !important;
}
`;

View File

@@ -0,0 +1,2 @@
export { useForecastData } from './useForecastData';
export type { UseForecastDataReturn } from './useForecastData';

View File

@@ -0,0 +1,72 @@
/**
* 盈利预测数据 Hook
* 特性:组件级缓存、请求取消、错误处理
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { forecastService } from '../services';
import type { ForecastData } from '../types';
// 组件级缓存 - 避免频繁切换时重复请求
const forecastCache = new Map<string, ForecastData>();
export interface UseForecastDataReturn {
data: ForecastData | null;
isLoading: boolean;
error: string | null;
refetch: () => void;
}
/**
* 获取盈利预测数据
* @param stockCode 股票代码
*/
export const useForecastData = (stockCode?: string): UseForecastDataReturn => {
const [data, setData] = useState<ForecastData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async () => {
if (!stockCode) return;
// 检查缓存
if (forecastCache.has(stockCode)) {
setData(forecastCache.get(stockCode)!);
return;
}
// 取消之前的请求
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setIsLoading(true);
setError(null);
try {
const resp = await forecastService.getForecastReport(
stockCode,
{ signal: abortControllerRef.current.signal }
);
if (resp?.success && resp.data) {
forecastCache.set(stockCode, resp.data);
setData(resp.data);
} else {
setError('获取盈利预测数据失败');
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message || '加载失败');
}
} finally {
setIsLoading(false);
}
}, [stockCode]);
useEffect(() => {
fetchData();
return () => abortControllerRef.current?.abort();
}, [fetchData]);
return { data, isLoading, error, refetch: fetchData };
};

View File

@@ -1,10 +1,11 @@
/** /**
* 盈利预测报表视图 - 黑金主题 * 盈利预测报表视图 - 黑金主题
* 优化:使用 useForecastData Hook、错误处理、memo 包装
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { memo } from 'react';
import { Box, SimpleGrid } from '@chakra-ui/react'; import { Box, SimpleGrid, Center, VStack, Text, Button } from '@chakra-ui/react';
import { stockService } from '@services/eventService'; import { useForecastData } from './hooks';
import { import {
IncomeProfitGrowthChart, IncomeProfitGrowthChart,
EpsChart, EpsChart,
@@ -12,68 +13,51 @@ import {
DetailTable, DetailTable,
} from './components'; } from './components';
import LoadingState from '../LoadingState'; import LoadingState from '../LoadingState';
import { CHART_HEIGHT } from './constants'; import type { ForecastReportProps } from './types';
import type { ForecastReportProps, ForecastData } from './types';
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => { const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
const [code, setCode] = useState(propStockCode || '600000'); const { data, isLoading, error, refetch } = useForecastData(stockCode);
const [data, setData] = useState<ForecastData | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => { // 加载状态
if (!code) return; if (isLoading && !data) {
setLoading(true); return <LoadingState message="加载盈利预测数据中..." height="300px" />;
try { }
const resp = await stockService.getForecastReport(code);
if (resp && resp.success) {
setData(resp.data);
}
} finally {
setLoading(false);
}
}, [code]);
// 监听 props 中的 stockCode 变化 // 错误状态
useEffect(() => { if (error && !data) {
if (propStockCode && propStockCode !== code) { return (
setCode(propStockCode); <Center h="200px">
} <VStack spacing={3}>
}, [propStockCode, code]); <Text color="red.400">{error}</Text>
<Button size="sm" colorScheme="yellow" variant="outline" onClick={refetch}>
</Button>
</VStack>
</Center>
);
}
// 加载数据 // 数据
useEffect(() => { if (!data) return null;
if (code) {
load();
}
}, [code, load]);
return ( return (
<Box> <Box>
{/* 加载状态 */}
{loading && !data && (
<LoadingState message="加载盈利预测数据中..." height="300px" />
)}
{/* 图表区域 - 3列布局 */} {/* 图表区域 - 3列布局 */}
{data && ( <SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}> <IncomeProfitGrowthChart
<IncomeProfitGrowthChart incomeProfitData={data.income_profit_trend}
incomeProfitData={data.income_profit_trend} growthData={data.growth_bars}
growthData={data.growth_bars} />
/> <EpsChart data={data.eps_trend} />
<EpsChart data={data.eps_trend} /> <PePegChart data={data.pe_peg_axes} />
<PePegChart data={data.pe_peg_axes} /> </SimpleGrid>
</SimpleGrid>
)}
{/* 详细数据表格 */} {/* 详细数据表格 */}
{data && ( <Box mt={4}>
<Box mt={4}> <DetailTable data={data.detail_table} />
<DetailTable data={data.detail_table} /> </Box>
</Box>
)}
</Box> </Box>
); );
}; };
export default ForecastReport; export default memo(ForecastReport);

View File

@@ -0,0 +1,30 @@
/**
* 盈利预测数据服务
*/
import { stockService } from '@services/eventService';
import type { ForecastData } from '../types';
export interface ForecastServiceOptions {
signal?: AbortSignal;
}
export interface ForecastServiceResponse {
success: boolean;
data: ForecastData | null;
}
export const forecastService = {
/**
* 获取盈利预测报表数据
* @param stockCode 股票代码
* @param options 请求选项
*/
async getForecastReport(
stockCode: string,
options?: ForecastServiceOptions
): Promise<ForecastServiceResponse> {
const resp = await stockService.getForecastReport(stockCode);
return resp;
},
};

View File

@@ -0,0 +1,2 @@
export { forecastService } from './forecastService';
export type { ForecastServiceOptions, ForecastServiceResponse } from './forecastService';

View File

@@ -0,0 +1,43 @@
/**
* 图表格式化工具函数
*/
// 从 constants 重新导出工具函数
export { isForecastYear, IMPORTANT_METRICS } from '../constants';
/**
* 格式化 Tooltip 值
* @param value 数值
* @param type 类型:金额、百分比、比率
*/
export const formatTooltipValue = (
value: number,
type: 'amount' | 'percent' | 'ratio'
): string => {
if (type === 'amount') {
return `${(value / 100000000).toFixed(2)}亿`;
}
if (type === 'percent') {
return `${value.toFixed(2)}%`;
}
return value.toFixed(2);
};
/**
* 查找预测年份起始索引
* @param years 年份数组
*/
export const findForecastStartIndex = (years: string[]): number => {
return years.findIndex(year => year.includes('E'));
};
/**
* 格式化金额(百万 → 亿)
* @param value 金额(百万元)
*/
export const formatAmount = (value: number): string => {
if (Math.abs(value) >= 1000) {
return `${(value / 1000).toFixed(1)}k`;
}
return value.toFixed(0);
};

View File

@@ -0,0 +1,7 @@
export {
isForecastYear,
IMPORTANT_METRICS,
formatTooltipValue,
findForecastStartIndex,
formatAmount,
} from './chartFormatters';

View File

@@ -20,6 +20,7 @@ import {
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared'; import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
import { dayCardStyle } from '../shared/styles';
import type { BigDealData } from '../../types'; import type { BigDealData } from '../../types';
export interface BigDealPanelProps { export interface BigDealPanelProps {
@@ -32,14 +33,7 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? ( {bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{bigDealData.daily_stats.map((dayStats, idx) => ( {bigDealData.daily_stats.map((dayStats, idx) => (
<Box <Box key={idx} sx={dayCardStyle}>
key={idx}
p={4}
bg="rgba(212, 175, 55, 0.05)"
borderRadius="lg"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
>
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}> <HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}> <Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
{dayStats.date} {dayStats.date}

View File

@@ -1,21 +1,14 @@
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx // src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
// 融资融券面板 - 黑金主题 // 融资融券面板 - 黑金主题
import React, { memo } from 'react'; import React, { memo, useMemo } from 'react';
import { import { Box, VStack, Grid } from '@chakra-ui/react';
Box,
Text,
VStack,
HStack,
Grid,
Heading,
} from '@chakra-ui/react';
import ECharts from '@components/Charts/ECharts'; import ECharts from '@components/Charts/ECharts';
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { getFundingDarkGoldOption } from '../../utils/chartOptions'; import { getFundingDarkGoldOption } from '../../utils/chartOptions';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import { DarkGoldCard } from '../shared'; import { DarkGoldCard, DataRow } from '../shared';
import { darkGoldCardFullStyle } from '../shared/styles'; import { darkGoldCardFullStyle } from '../shared/styles';
import type { FundingDayData } from '../../types'; import type { FundingDayData } from '../../types';
@@ -24,6 +17,17 @@ export interface FundingPanelProps {
} }
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => { const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
// 缓存图表配置
const chartOption = useMemo(() => {
if (fundingData.length === 0) return {};
return getFundingDarkGoldOption(fundingData);
}, [fundingData]);
// 缓存最近5条数据倒序
const recentData = useMemo(() => {
return fundingData.slice(-5).reverse();
}, [fundingData]);
return ( return (
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 图表卡片 */} {/* 图表卡片 */}
@@ -31,7 +35,7 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
{fundingData.length > 0 && ( {fundingData.length > 0 && (
<Box h="400px"> <Box h="400px">
<ECharts <ECharts
option={getFundingDarkGoldOption(fundingData)} option={chartOption}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
theme="dark" theme="dark"
/> />
@@ -43,78 +47,30 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
{/* 融资数据 */} {/* 融资数据 */}
<DarkGoldCard title="融资数据" titleColor={darkGoldTheme.gold}> <DarkGoldCard title="融资数据" titleColor={darkGoldTheme.gold}>
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{fundingData {recentData.map((item, idx) => (
.slice(-5) <DataRow
.reverse() key={idx}
.map((item, idx) => ( variant="gold"
<Box label={item.date}
key={idx} value={formatNumber(item.financing.balance)}
p={3} subValue={`买入${formatNumber(item.financing.buy)} / 偿还${formatNumber(item.financing.repay)}`}
bg="rgba(212, 175, 55, 0.08)" />
borderRadius="md" ))}
border="1px solid"
borderColor="rgba(212, 175, 55, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(212, 175, 55, 0.12)',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
>
<HStack justify="space-between">
<Text color={darkGoldTheme.textMuted} fontSize="sm">
{item.date}
</Text>
<VStack align="end" spacing={0}>
<Text color={darkGoldTheme.gold} fontWeight="bold">
{formatNumber(item.financing.balance)}
</Text>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{formatNumber(item.financing.buy)} /
{formatNumber(item.financing.repay)}
</Text>
</VStack>
</HStack>
</Box>
))}
</VStack> </VStack>
</DarkGoldCard> </DarkGoldCard>
{/* 融券数据 */} {/* 融券数据 */}
<DarkGoldCard title="融券数据" titleColor={darkGoldTheme.orange}> <DarkGoldCard title="融券数据" titleColor={darkGoldTheme.orange}>
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{fundingData {recentData.map((item, idx) => (
.slice(-5) <DataRow
.reverse() key={idx}
.map((item, idx) => ( variant="orange"
<Box label={item.date}
key={idx} value={formatNumber(item.securities.balance)}
p={3} subValue={`卖出${formatNumber(item.securities.sell)} / 偿还${formatNumber(item.securities.repay)}`}
bg="rgba(255, 149, 0, 0.08)" />
borderRadius="md" ))}
border="1px solid"
borderColor="rgba(255, 149, 0, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 149, 0, 0.12)',
borderColor: 'rgba(255, 149, 0, 0.3)',
}}
>
<HStack justify="space-between">
<Text color={darkGoldTheme.textMuted} fontSize="sm">
{item.date}
</Text>
<VStack align="end" spacing={0}>
<Text color={darkGoldTheme.orange} fontWeight="bold">
{formatNumber(item.securities.balance)}
</Text>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{formatNumber(item.securities.sell)} /
{formatNumber(item.securities.repay)}
</Text>
</VStack>
</HStack>
</Box>
))}
</VStack> </VStack>
</DarkGoldCard> </DarkGoldCard>
</Grid> </Grid>

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx // src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
// 股权质押面板 - 黑金主题 // 股权质押面板 - 黑金主题
import React, { memo } from 'react'; import React, { memo, useMemo } from 'react';
import { import {
Box, Box,
Text, Text,
@@ -28,6 +28,12 @@ export interface PledgePanelProps {
} }
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => { const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
// 缓存图表配置
const chartOption = useMemo(() => {
if (pledgeData.length === 0) return {};
return getPledgeDarkGoldOption(pledgeData);
}, [pledgeData]);
return ( return (
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 图表卡片 */} {/* 图表卡片 */}
@@ -35,7 +41,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
{pledgeData.length > 0 && ( {pledgeData.length > 0 && (
<Box h="400px"> <Box h="400px">
<ECharts <ECharts
option={getPledgeDarkGoldOption(pledgeData)} option={chartOption}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
theme="dark" theme="dark"
/> />

View File

@@ -2,17 +2,12 @@
// 龙虎榜面板 - 黑金主题 // 龙虎榜面板 - 黑金主题
import React, { memo } from 'react'; import React, { memo } from 'react';
import { import { Box, Text, VStack, HStack, Grid } from '@chakra-ui/react';
Box,
Text,
VStack,
HStack,
Grid,
} from '@chakra-ui/react';
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared'; import { DarkGoldCard, DarkGoldBadge, EmptyState, DataRow } from '../shared';
import { dayCardStyle } from '../shared/styles';
import type { UnusualData } from '../../types'; import type { UnusualData } from '../../types';
export interface UnusualPanelProps { export interface UnusualPanelProps {
@@ -23,144 +18,101 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
return ( return (
<DarkGoldCard title="龙虎榜数据"> <DarkGoldCard title="龙虎榜数据">
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? ( {unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{unusualData.grouped_data.map((dayData, idx) => ( {unusualData.grouped_data.map((dayData, idx) => (
<Box <Box key={idx} sx={dayCardStyle}>
key={idx} <HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
p={4} <Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
bg="rgba(212, 175, 55, 0.05)" {dayData.date}
borderRadius="lg" </Text>
border="1px solid" <HStack spacing={2} flexWrap="wrap">
borderColor="rgba(212, 175, 55, 0.15)" <DarkGoldBadge variant="red">
> : {formatNumber(dayData.total_buy)}
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}> </DarkGoldBadge>
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}> <DarkGoldBadge variant="green">
{dayData.date} : {formatNumber(dayData.total_sell)}
</Text> </DarkGoldBadge>
<HStack spacing={2} flexWrap="wrap"> <DarkGoldBadge variant={dayData.net_amount > 0 ? 'red' : 'green'}>
<DarkGoldBadge variant="red"> : {formatNumber(dayData.net_amount)}
: {formatNumber(dayData.total_buy)} </DarkGoldBadge>
</DarkGoldBadge>
<DarkGoldBadge variant="green">
: {formatNumber(dayData.total_sell)}
</DarkGoldBadge>
<DarkGoldBadge variant={dayData.net_amount > 0 ? 'red' : 'green'}>
: {formatNumber(dayData.net_amount)}
</DarkGoldBadge>
</HStack>
</HStack> </HStack>
</HStack>
<Grid templateColumns="repeat(2, 1fr)" gap={4}> <Grid templateColumns="repeat(2, 1fr)" gap={4}>
<Box> <Box>
<Text fontWeight="bold" color={darkGoldTheme.red} mb={2} fontSize="sm"> <Text fontWeight="bold" color={darkGoldTheme.red} mb={2} fontSize="sm">
</Text>
<VStack spacing={1} align="stretch">
{dayData.buyers && dayData.buyers.length > 0 ? (
dayData.buyers.slice(0, 5).map((buyer, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg="rgba(255, 68, 68, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 68, 68, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 68, 68, 0.12)',
borderColor: 'rgba(255, 68, 68, 0.3)',
}}
>
<Text
fontSize="xs"
color={darkGoldTheme.textSecondary}
isTruncated
maxW="70%"
>
{buyer.dept_name}
</Text>
<Text fontSize="xs" color={darkGoldTheme.red} fontWeight="bold">
{formatNumber(buyer.buy_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
)}
</VStack>
</Box>
<Box>
<Text fontWeight="bold" color={darkGoldTheme.green} mb={2} fontSize="sm">
</Text>
<VStack spacing={1} align="stretch">
{dayData.sellers && dayData.sellers.length > 0 ? (
dayData.sellers.slice(0, 5).map((seller, i) => (
<HStack
key={i}
justify="space-between"
p={2}
bg="rgba(0, 200, 81, 0.08)"
borderRadius="md"
border="1px solid"
borderColor="rgba(0, 200, 81, 0.15)"
transition="all 0.2s"
_hover={{
bg: 'rgba(0, 200, 81, 0.12)',
borderColor: 'rgba(0, 200, 81, 0.3)',
}}
>
<Text
fontSize="xs"
color={darkGoldTheme.textSecondary}
isTruncated
maxW="70%"
>
{seller.dept_name}
</Text>
<Text fontSize="xs" color={darkGoldTheme.green} fontWeight="bold">
{formatNumber(seller.sell_amount)}
</Text>
</HStack>
))
) : (
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
)}
</VStack>
</Box>
</Grid>
{/* 信息类型标签 */}
<HStack mt={3} spacing={2} flexWrap="wrap">
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
:
</Text> </Text>
{dayData.info_types?.map((type, i) => ( <VStack spacing={1} align="stretch">
<Box {dayData.buyers && dayData.buyers.length > 0 ? (
key={i} dayData.buyers.slice(0, 5).map((buyer, i) => (
px={2} <DataRow
py={0.5} key={i}
bg="rgba(212, 175, 55, 0.1)" variant="red"
color={darkGoldTheme.gold} label={buyer.dept_name}
borderRadius="sm" value={formatNumber(buyer.buy_amount)}
fontSize="xs" isTruncated
> maxLabelWidth="70%"
{type} />
</Box> ))
))} ) : (
</HStack> <Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Box>
))} </Text>
</VStack> )}
) : ( </VStack>
<EmptyState message="暂无龙虎榜数据" /> </Box>
)}
<Box>
<Text fontWeight="bold" color={darkGoldTheme.green} mb={2} fontSize="sm">
</Text>
<VStack spacing={1} align="stretch">
{dayData.sellers && dayData.sellers.length > 0 ? (
dayData.sellers.slice(0, 5).map((seller, i) => (
<DataRow
key={i}
variant="green"
label={seller.dept_name}
value={formatNumber(seller.sell_amount)}
isTruncated
maxLabelWidth="70%"
/>
))
) : (
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
</Text>
)}
</VStack>
</Box>
</Grid>
{/* 信息类型标签 */}
<HStack mt={3} spacing={2} flexWrap="wrap">
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
:
</Text>
{dayData.info_types?.map((type, i) => (
<Box
key={i}
px={2}
py={0.5}
bg="rgba(212, 175, 55, 0.1)"
color={darkGoldTheme.gold}
borderRadius="sm"
fontSize="xs"
>
{type}
</Box>
))}
</HStack>
</Box>
))}
</VStack>
) : (
<EmptyState message="暂无龙虎榜数据" />
)}
</DarkGoldCard> </DarkGoldCard>
); );
}; };

View File

@@ -0,0 +1,84 @@
// src/views/Company/components/MarketDataView/components/shared/DataRow.tsx
// 通用数据行原子组件
import React, { memo } from 'react';
import { Box, Text, VStack, HStack } from '@chakra-ui/react';
import { darkGoldTheme } from '../../constants';
import {
financingRowStyle,
securitiesRowStyle,
buyRowStyle,
sellRowStyle,
} from './styles';
export type DataRowVariant = 'gold' | 'orange' | 'red' | 'green';
export interface DataRowProps {
/** 样式变体 */
variant: DataRowVariant;
/** 左侧标签 */
label: React.ReactNode;
/** 主要值 */
value: React.ReactNode;
/** 次要值(可选) */
subValue?: React.ReactNode;
/** 标签是否可截断 */
isTruncated?: boolean;
/** 标签最大宽度 */
maxLabelWidth?: string;
}
// 样式映射
const styleMap = {
gold: financingRowStyle,
orange: securitiesRowStyle,
red: buyRowStyle,
green: sellRowStyle,
};
// 颜色映射
const colorMap = {
gold: darkGoldTheme.gold,
orange: darkGoldTheme.orange,
red: darkGoldTheme.red,
green: darkGoldTheme.green,
};
/**
* 通用数据行组件
* 用于融资融券、龙虎榜等列表展示
*/
const DataRow: React.FC<DataRowProps> = ({
variant,
label,
value,
subValue,
isTruncated = false,
maxLabelWidth,
}) => {
return (
<HStack justify="space-between" sx={styleMap[variant]}>
<Text
color={darkGoldTheme.textMuted}
fontSize="sm"
isTruncated={isTruncated}
maxW={maxLabelWidth}
>
{label}
</Text>
<VStack align="end" spacing={0}>
<Text color={colorMap[variant]} fontWeight="bold">
{value}
</Text>
{subValue && (
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{subValue}
</Text>
)}
</VStack>
</HStack>
);
};
export default memo(DataRow);

View File

@@ -4,5 +4,21 @@
export { default as DarkGoldCard } from './DarkGoldCard'; export { default as DarkGoldCard } from './DarkGoldCard';
export { default as DarkGoldBadge } from './DarkGoldBadge'; export { default as DarkGoldBadge } from './DarkGoldBadge';
export { default as EmptyState } from './EmptyState'; export { default as EmptyState } from './EmptyState';
export { darkGoldCardStyle, darkGoldCardHoverStyle } from './styles'; export { default as DataRow } from './DataRow';
export {
darkGoldCardStyle,
darkGoldCardHoverStyle,
darkGoldCardFullStyle,
dataRowStyle,
tableRowHoverStyle,
tableBorderStyle,
financingRowStyle,
securitiesRowStyle,
buyRowStyle,
sellRowStyle,
dayCardStyle,
} from './styles';
export type { DarkGoldBadgeVariant } from './DarkGoldBadge'; export type { DarkGoldBadgeVariant } from './DarkGoldBadge';
export type { DataRowVariant, DataRowProps } from './DataRow';

View File

@@ -57,3 +57,78 @@ export const tableBorderStyle: SystemStyleObject = {
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.1)', borderColor: 'rgba(212, 175, 55, 0.1)',
}; };
/**
* 融资行样式 (金色主题)
*/
export const financingRowStyle: SystemStyleObject = {
p: 3,
bg: 'rgba(212, 175, 55, 0.08)',
borderRadius: 'md',
border: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.15)',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(212, 175, 55, 0.12)',
borderColor: 'rgba(212, 175, 55, 0.3)',
},
};
/**
* 融券行样式 (橙色主题)
*/
export const securitiesRowStyle: SystemStyleObject = {
p: 3,
bg: 'rgba(255, 149, 0, 0.08)',
borderRadius: 'md',
border: '1px solid',
borderColor: 'rgba(255, 149, 0, 0.15)',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(255, 149, 0, 0.12)',
borderColor: 'rgba(255, 149, 0, 0.3)',
},
};
/**
* 买入行样式 (红色主题)
*/
export const buyRowStyle: SystemStyleObject = {
p: 2,
bg: 'rgba(255, 68, 68, 0.08)',
borderRadius: 'md',
border: '1px solid',
borderColor: 'rgba(255, 68, 68, 0.15)',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(255, 68, 68, 0.12)',
borderColor: 'rgba(255, 68, 68, 0.3)',
},
};
/**
* 卖出行样式 (绿色主题)
*/
export const sellRowStyle: SystemStyleObject = {
p: 2,
bg: 'rgba(0, 200, 81, 0.08)',
borderRadius: 'md',
border: '1px solid',
borderColor: 'rgba(0, 200, 81, 0.15)',
transition: 'all 0.2s',
_hover: {
bg: 'rgba(0, 200, 81, 0.12)',
borderColor: 'rgba(0, 200, 81, 0.3)',
},
};
/**
* 日期数据卡片样式
*/
export const dayCardStyle: SystemStyleObject = {
p: 4,
bg: 'rgba(212, 175, 55, 0.05)',
borderRadius: 'lg',
border: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.15)',
};