Compare commits
10 Commits
4e71623477
...
2cc16be585
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc16be585 | ||
|
|
11ca0e7a99 | ||
|
|
ff951972ee | ||
|
|
41da6fa372 | ||
|
|
54cce55c29 | ||
|
|
0e29f1aff4 | ||
|
|
7b58f83490 | ||
|
|
22062a6556 | ||
|
|
94854fee3e | ||
|
|
852d5fd188 |
@@ -8,12 +8,12 @@
|
||||
FinancialPanorama/
|
||||
├── index.tsx # 主组件入口
|
||||
├── types.ts # 类型定义
|
||||
├── constants.ts # 常量配置
|
||||
├── constants.ts # 常量配置(指标定义、颜色等)
|
||||
├── README.md # 本文档
|
||||
│
|
||||
├── hooks/
|
||||
│ ├── index.ts # Hooks 导出
|
||||
│ └── useFinancialData.ts # 财务数据获取
|
||||
│ └── useFinancialData.ts # 财务数据获取 Hook
|
||||
│
|
||||
├── components/ # 子组件
|
||||
│ ├── index.ts # 统一导出
|
||||
@@ -21,55 +21,94 @@ FinancialPanorama/
|
||||
│ ├── StockInfoHeader.tsx # 股票信息头部
|
||||
│ ├── KeyMetricsOverview.tsx # 关键指标概览
|
||||
│ ├── FinancialOverviewPanel.tsx # 财务概览面板
|
||||
│ ├── FinancialTable.tsx # 财务表格基础组件
|
||||
│ ├── IncomeStatementTable.tsx # 利润表
|
||||
│ ├── BalanceSheetTable.tsx # 资产负债表
|
||||
│ ├── CashflowTable.tsx # 现金流量表
|
||||
│ ├── IncomeStatementTable.tsx # 利润表(memo 优化)
|
||||
│ ├── BalanceSheetTable.tsx # 资产负债表(memo 优化)
|
||||
│ ├── CashflowTable.tsx # 现金流量表(memo 优化)
|
||||
│ ├── FinancialMetricsTable.tsx # 财务指标表
|
||||
│ ├── MetricChartModal.tsx # 指标图表弹窗(新增)
|
||||
│ ├── MainBusinessAnalysis.tsx # 主营业务分析
|
||||
│ ├── IndustryRankingView.tsx # 行业排名视图
|
||||
│ ├── StockComparison.tsx # 股票对比
|
||||
│ └── ComparisonAnalysis.tsx # 对比分析
|
||||
│
|
||||
├── tabs/ # Tab 页面
|
||||
├── tabs/ # Tab 页面(全部 memo 优化)
|
||||
│ ├── index.ts # 统一导出
|
||||
│ ├── IncomeStatementTab.tsx # 利润表 Tab
|
||||
│ ├── MetricsCategoryTab.tsx # 指标分类 Tab(含 7 个分类子组件)
|
||||
│ ├── BalanceSheetTab.tsx # 资产负债表 Tab
|
||||
│ ├── IncomeStatementTab.tsx # 利润表 Tab
|
||||
│ ├── CashflowTab.tsx # 现金流量表 Tab
|
||||
│ ├── FinancialMetricsTab.tsx # 财务指标 Tab
|
||||
│ └── MetricsCategoryTab.tsx # 指标分类 Tab
|
||||
│ └── FinancialMetricsTab.tsx # 财务指标 Tab
|
||||
│
|
||||
└── utils/
|
||||
├── index.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
|
||||
├── PeriodSelector # 期数选择
|
||||
├── SubTabContainer # Tab 容器
|
||||
│ ├── IncomeStatementTab # 利润表
|
||||
│ │ └── IncomeStatementTable
|
||||
├── FinancialOverviewPanel # 财务概览(三列布局)
|
||||
├── ComparisonAnalysis # 营收与利润趋势
|
||||
├── MainBusinessAnalysis # 主营业务分析
|
||||
├── SubTabContainer # Tab 容器(10 个 Tab)
|
||||
│ ├── ProfitabilityTab # 盈利能力
|
||||
│ ├── PerShareTab # 每股指标
|
||||
│ ├── GrowthTab # 成长能力
|
||||
│ ├── OperationalTab # 运营效率
|
||||
│ ├── SolvencyTab # 偿债能力
|
||||
│ ├── ExpenseTab # 费用率
|
||||
│ ├── CashflowMetricsTab # 现金流指标
|
||||
│ ├── BalanceSheetTab # 资产负债表
|
||||
│ │ └── BalanceSheetTable
|
||||
│ ├── CashflowTab # 现金流量表
|
||||
│ │ └── CashflowTable
|
||||
│ └── FinancialMetricsTab # 财务指标
|
||||
│ └── FinancialMetricsTable
|
||||
│ ├── IncomeStatementTab # 利润表
|
||||
│ │ └── IncomeStatementTable
|
||||
│ └── CashflowTab # 现金流量表
|
||||
│ └── CashflowTable
|
||||
└── MetricChartModal # 指标图表弹窗
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
@@ -79,3 +118,28 @@ import FinancialPanorama from '@views/Company/components/FinancialPanorama';
|
||||
|
||||
<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 实现懒加载
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 资产负债表组件 - 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 { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -17,79 +17,11 @@ import {
|
||||
TOTAL_LIABILITIES_METRICS,
|
||||
EQUITY_METRICS,
|
||||
} from '../constants';
|
||||
import { getValueByPath } from '../utils';
|
||||
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
||||
import type { BalanceSheetTableProps, MetricConfig } from '../types';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.balance-sheet-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
font-weight: 600;
|
||||
color: #D4AF37;
|
||||
}
|
||||
.balance-sheet-table .ant-table-cell-fix-left,
|
||||
.balance-sheet-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.balance-sheet-table .positive-change {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.balance-sheet-table .negative-change {
|
||||
color: #48BB78;
|
||||
}
|
||||
.balance-sheet-table .ant-table-placeholder {
|
||||
background: transparent !important;
|
||||
}
|
||||
.balance-sheet-table .ant-empty-description {
|
||||
color: #A0AEC0;
|
||||
}
|
||||
`;
|
||||
const TABLE_CLASS_NAME = 'balance-sheet-table';
|
||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME);
|
||||
|
||||
// 表格行数据类型
|
||||
interface TableRowData {
|
||||
@@ -103,7 +35,7 @@ interface TableRowData {
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
const BalanceSheetTableInner: React.FC<BalanceSheetTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -172,29 +104,13 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
return rows;
|
||||
}, [data, displayData]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
// 计算同比变化(使用共享函数)
|
||||
const calcYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
@@ -236,7 +152,7 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
render: (value: number | undefined, record: TableRowData) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -296,7 +212,7 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
return (
|
||||
<Box className="balance-sheet-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
@@ -323,4 +239,5 @@ export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const BalanceSheetTable = memo(BalanceSheetTableInner);
|
||||
export default BalanceSheetTable;
|
||||
|
||||
@@ -2,82 +2,24 @@
|
||||
* 现金流量表组件 - 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 { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { CASHFLOW_METRICS } from '../constants';
|
||||
import { getValueByPath } from '../utils';
|
||||
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
||||
import type { CashflowTableProps } from '../types';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.cashflow-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.cashflow-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.cashflow-table .ant-table-cell-fix-left,
|
||||
.cashflow-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.cashflow-table .positive-value {
|
||||
const TABLE_CLASS_NAME = 'cashflow-table';
|
||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
||||
.${TABLE_CLASS_NAME} .positive-value {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.cashflow-table .negative-value {
|
||||
.${TABLE_CLASS_NAME} .negative-value {
|
||||
color: #48BB78;
|
||||
}
|
||||
.cashflow-table .positive-change {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.cashflow-table .negative-change {
|
||||
color: #48BB78;
|
||||
}
|
||||
.cashflow-table .ant-table-placeholder {
|
||||
background: transparent !important;
|
||||
}
|
||||
.cashflow-table .ant-empty-description {
|
||||
color: #A0AEC0;
|
||||
}
|
||||
`;
|
||||
|
||||
// 核心指标
|
||||
@@ -92,7 +34,7 @@ interface TableRowData {
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
const CashflowTableInner: React.FC<CashflowTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -131,29 +73,13 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
});
|
||||
}, [data, displayData]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
// 计算同比变化(使用共享函数)
|
||||
const calcYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
@@ -188,7 +114,7 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
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 isNegative = value !== undefined && value < 0;
|
||||
|
||||
@@ -246,7 +172,7 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
return (
|
||||
<Box className="cashflow-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
@@ -266,4 +192,5 @@ export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CashflowTable = memo(CashflowTableInner);
|
||||
export default CashflowTable;
|
||||
|
||||
@@ -2,92 +2,25 @@
|
||||
* 利润表组件 - 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 { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { INCOME_STATEMENT_SECTIONS } from '../constants';
|
||||
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
||||
import type { IncomeStatementTableProps, MetricConfig } from '../types';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.income-statement-table .ant-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
.income-statement-table .ant-table-thead > tr > th {
|
||||
background: rgba(26, 32, 44, 0.8) !important;
|
||||
color: #D4AF37 !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||
color: #E2E8F0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr.total-row > td {
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
|
||||
const TABLE_CLASS_NAME = 'income-statement-table';
|
||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
||||
.${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
|
||||
background: rgba(212, 175, 55, 0.1) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr.section-header > td {
|
||||
background: rgba(212, 175, 55, 0.08) !important;
|
||||
font-weight: 600;
|
||||
color: #D4AF37;
|
||||
}
|
||||
.income-statement-table .ant-table-cell-fix-left,
|
||||
.income-statement-table .ant-table-cell-fix-right {
|
||||
background: #1A202C !important;
|
||||
}
|
||||
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
|
||||
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
|
||||
background: rgba(26, 32, 44, 0.95) !important;
|
||||
}
|
||||
.income-statement-table .positive-change {
|
||||
.${TABLE_CLASS_NAME} .negative-value {
|
||||
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;
|
||||
}
|
||||
|
||||
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||
const IncomeStatementTableInner: React.FC<IncomeStatementTableProps> = ({
|
||||
data,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -160,29 +93,13 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||
return rows;
|
||||
}, [data, displayData]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
// 计算同比变化(使用共享函数)
|
||||
const calcYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const lastYearPeriod = data.find((item) => {
|
||||
const date = new Date(item.period);
|
||||
return (
|
||||
date.getFullYear() === currentDate.getFullYear() - 1 &&
|
||||
date.getMonth() === currentDate.getMonth()
|
||||
);
|
||||
});
|
||||
|
||||
if (!lastYearPeriod) return null;
|
||||
|
||||
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
|
||||
if (lastYearValue === undefined || lastYearValue === 0) return null;
|
||||
|
||||
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
|
||||
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
@@ -224,7 +141,7 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||
render: (value: number | undefined, record: TableRowData) => {
|
||||
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 formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
|
||||
const isNegative = value !== undefined && value < 0;
|
||||
@@ -295,7 +212,7 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||
return (
|
||||
<Box className="income-statement-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
@@ -323,4 +240,5 @@ export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const IncomeStatementTable = memo(IncomeStatementTableInner);
|
||||
export default IncomeStatementTable;
|
||||
|
||||
@@ -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;
|
||||
@@ -15,3 +15,5 @@ export { MainBusinessAnalysis } from './MainBusinessAnalysis';
|
||||
export { IndustryRankingView } from './IndustryRankingView';
|
||||
export { StockComparison } from './StockComparison';
|
||||
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
||||
export { MetricChartModal } from './MetricChartModal';
|
||||
export type { MetricChartModalProps } from './MetricChartModal';
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
CashflowData,
|
||||
FinancialMetricsData,
|
||||
MainBusinessData,
|
||||
ForecastData,
|
||||
IndustryRankData,
|
||||
ComparisonData,
|
||||
} from '../types';
|
||||
|
||||
@@ -51,8 +49,6 @@ interface UseFinancialDataReturn {
|
||||
cashflow: CashflowData[];
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
mainBusiness: MainBusinessData | null;
|
||||
forecast: ForecastData | null;
|
||||
industryRank: IndustryRankData[];
|
||||
comparison: ComparisonData[];
|
||||
|
||||
// 加载状态
|
||||
@@ -100,8 +96,6 @@ export const useFinancialData = (
|
||||
const [cashflow, setCashflow] = useState<CashflowData[]>([]);
|
||||
const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]);
|
||||
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 toast = useToast();
|
||||
@@ -304,8 +298,6 @@ export const useFinancialData = (
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
mainBusiness,
|
||||
forecast,
|
||||
industryRank,
|
||||
comparison,
|
||||
|
||||
// 加载状态
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -13,21 +13,7 @@ import {
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -41,8 +27,6 @@ import {
|
||||
Receipt,
|
||||
Banknote,
|
||||
} from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
|
||||
// 通用组件
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
@@ -51,8 +35,14 @@ import LoadingState from '../LoadingState';
|
||||
// 内部模块导入
|
||||
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||
import { COLORS } from './constants';
|
||||
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
|
||||
import { PeriodSelector, FinancialOverviewPanel, MainBusinessAnalysis, ComparisonAnalysis } from './components';
|
||||
import { calculateYoYChange, getCellBackground } from './utils';
|
||||
import {
|
||||
PeriodSelector,
|
||||
FinancialOverviewPanel,
|
||||
MainBusinessAnalysis,
|
||||
ComparisonAnalysis,
|
||||
MetricChartModal,
|
||||
} from './components';
|
||||
import {
|
||||
BalanceSheetTab,
|
||||
IncomeStatementTab,
|
||||
@@ -117,111 +107,22 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
|
||||
// UI 状态
|
||||
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((
|
||||
metricName: string,
|
||||
_metricKey: string,
|
||||
data: Array<{ period: string; [key: string]: unknown }>,
|
||||
dataPath: string
|
||||
) => {
|
||||
const chartData = data
|
||||
.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>
|
||||
);
|
||||
setModalProps({ metricName, data, dataPath });
|
||||
onOpen();
|
||||
}, [onOpen, positiveColor, negativeColor]);
|
||||
}, [onOpen]);
|
||||
|
||||
// Tab 配置 - 财务指标分类 + 三大财务报表
|
||||
const tabConfigs: SubTabConfig[] = useMemo(
|
||||
@@ -242,7 +143,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
[]
|
||||
);
|
||||
|
||||
// 传递给 Tab 组件的 props
|
||||
// 传递给 Tab 组件的 props(颜色使用常量,不需要在依赖数组中)
|
||||
const componentProps = useMemo(
|
||||
() => ({
|
||||
// 数据
|
||||
@@ -257,11 +158,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
// 颜色配置
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
// 颜色配置(使用常量)
|
||||
...COLORS,
|
||||
}),
|
||||
[
|
||||
balanceSheet,
|
||||
@@ -271,10 +169,6 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
loading,
|
||||
loadingTab,
|
||||
showMetricChart,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
bgColor,
|
||||
hoverBg,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -335,15 +229,14 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 弹出模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="900px">
|
||||
<ModalHeader>指标详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>{modalContent}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{/* 指标图表弹窗 */}
|
||||
<MetricChartModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
metricName={modalProps.metricName}
|
||||
data={modalProps.data}
|
||||
dataPath={modalProps.dataPath}
|
||||
/>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 资产负债表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
||||
import { BalanceSheetTable } from '../components';
|
||||
import type { BalanceSheetData } from '../types';
|
||||
@@ -19,7 +19,7 @@ export interface BalanceSheetTabProps {
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
const BalanceSheetTabInner: React.FC<BalanceSheetTabProps> = ({
|
||||
balanceSheet,
|
||||
loading,
|
||||
showMetricChart,
|
||||
@@ -72,4 +72,5 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const BalanceSheetTab = memo(BalanceSheetTabInner);
|
||||
export default BalanceSheetTab;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 现金流量表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
||||
import { CashflowTable } from '../components';
|
||||
import type { CashflowData } from '../types';
|
||||
@@ -19,7 +19,7 @@ export interface CashflowTabProps {
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
const CashflowTabInner: React.FC<CashflowTabProps> = ({
|
||||
cashflow,
|
||||
loading,
|
||||
showMetricChart,
|
||||
@@ -72,4 +72,5 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const CashflowTab = memo(CashflowTabInner);
|
||||
export default CashflowTab;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 财务指标 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Spinner, Center } from '@chakra-ui/react';
|
||||
import { FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData } from '../types';
|
||||
@@ -19,7 +19,7 @@ export interface FinancialMetricsTabProps {
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||
const FinancialMetricsTabInner: React.FC<FinancialMetricsTabProps> = ({
|
||||
financialMetrics,
|
||||
loading,
|
||||
showMetricChart,
|
||||
@@ -54,4 +54,5 @@ const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const FinancialMetricsTab = memo(FinancialMetricsTabInner);
|
||||
export default FinancialMetricsTab;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 利润表 Tab
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
|
||||
import { IncomeStatementTable } from '../components';
|
||||
import type { IncomeStatementData } from '../types';
|
||||
@@ -19,7 +19,7 @@ export interface IncomeStatementTabProps {
|
||||
hoverBg: string;
|
||||
}
|
||||
|
||||
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
const IncomeStatementTabInner: React.FC<IncomeStatementTabProps> = ({
|
||||
incomeStatement,
|
||||
loading,
|
||||
showMetricChart,
|
||||
@@ -72,4 +72,5 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeStatementTab = memo(IncomeStatementTabInner);
|
||||
export default IncomeStatementTab;
|
||||
|
||||
@@ -3,84 +3,26 @@
|
||||
* 接受 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 { Table, ConfigProvider, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { formatUtils } from '@services/financialService';
|
||||
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
||||
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
|
||||
import type { FinancialMetricsData } from '../types';
|
||||
|
||||
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
const BLACK_GOLD_THEME = {
|
||||
token: {
|
||||
colorBgContainer: 'transparent',
|
||||
colorText: '#E2E8F0',
|
||||
colorTextHeading: '#D4AF37',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
headerBg: 'rgba(26, 32, 44, 0.8)',
|
||||
headerColor: '#D4AF37',
|
||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
cellPaddingBlock: 8,
|
||||
cellPaddingInline: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 黑金主题CSS
|
||||
const tableStyles = `
|
||||
.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 {
|
||||
const TABLE_CLASS_NAME = 'metrics-category-table';
|
||||
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
||||
.${TABLE_CLASS_NAME} .positive-value {
|
||||
color: #E53E3E;
|
||||
}
|
||||
.metrics-category-table .negative-change {
|
||||
.${TABLE_CLASS_NAME} .negative-value {
|
||||
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 {
|
||||
@@ -105,7 +47,7 @@ interface TableRowData {
|
||||
[period: string]: unknown;
|
||||
}
|
||||
|
||||
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
const MetricsCategoryTabInner: React.FC<MetricsCategoryTabProps> = ({
|
||||
categoryKey,
|
||||
financialMetrics,
|
||||
loading,
|
||||
@@ -162,29 +104,13 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
});
|
||||
}, [financialMetrics, displayData, category]);
|
||||
|
||||
// 计算同比变化
|
||||
const calculateYoY = (
|
||||
// 计算同比变化(使用共享函数)
|
||||
const calcYoY = (
|
||||
currentValue: number | undefined,
|
||||
currentPeriod: string,
|
||||
path: string
|
||||
): number | null => {
|
||||
if (currentValue === undefined || currentValue === null) return null;
|
||||
|
||||
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;
|
||||
return calculateYoY(financialMetrics, currentValue, currentPeriod, path, getValueByPath);
|
||||
};
|
||||
|
||||
// 构建列定义
|
||||
@@ -219,7 +145,7 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
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);
|
||||
|
||||
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
||||
@@ -287,7 +213,7 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
<Box>
|
||||
<Box className="metrics-category-table">
|
||||
<style>{tableStyles}</style>
|
||||
<ConfigProvider theme={BLACK_GOLD_THEME}>
|
||||
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
@@ -309,33 +235,35 @@ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 为每个分类创建预配置的组件
|
||||
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||
const MetricsCategoryTab = memo(MetricsCategoryTabInner);
|
||||
|
||||
// 为每个分类创建预配置的组件(使用 memo)
|
||||
export const ProfitabilityTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((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} />
|
||||
);
|
||||
));
|
||||
|
||||
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||
export const GrowthTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((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} />
|
||||
);
|
||||
));
|
||||
|
||||
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||
export const SolvencyTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((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} />
|
||||
);
|
||||
));
|
||||
|
||||
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
|
||||
export const CashflowMetricsTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
|
||||
<MetricsCategoryTab categoryKey="cashflow" {...props} />
|
||||
);
|
||||
));
|
||||
|
||||
export default MetricsCategoryTab;
|
||||
|
||||
@@ -15,3 +15,9 @@ export {
|
||||
getMainBusinessPieOption,
|
||||
getCompareBarChartOption,
|
||||
} from './chartOptions';
|
||||
|
||||
export {
|
||||
BLACK_GOLD_TABLE_THEME,
|
||||
getTableStyles,
|
||||
calculateYoY,
|
||||
} from './tableTheme';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* 通用图表卡片组件 - 黑金主题
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, Heading } from '@chakra-ui/react';
|
||||
import { THEME } from '../constants';
|
||||
import type { ChartCardProps } from '../types';
|
||||
@@ -34,4 +34,4 @@ const ChartCard: React.FC<ChartCardProps> = ({ title, children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartCard;
|
||||
export default memo(ChartCard);
|
||||
|
||||
@@ -3,17 +3,12 @@
|
||||
* 优化:斑马纹、等宽字体、首列高亮、重要行强调、预测列区分
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useCallback, memo } from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { Table, ConfigProvider, Tag, theme as antTheme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { DetailTableProps, DetailTableRow } from '../types';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
// 重要指标(需要高亮的行)
|
||||
const IMPORTANT_METRICS = ['归母净利润', 'ROE', 'EPS', '营业总收入'];
|
||||
import { isForecastYear, IMPORTANT_METRICS, DETAIL_TABLE_STYLES } from '../constants';
|
||||
|
||||
// Ant Design 黑金主题配置
|
||||
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
|
||||
type TableRowData = {
|
||||
key: string;
|
||||
@@ -193,14 +114,14 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
// 行类名
|
||||
const rowClassName = (record: TableRowData) => {
|
||||
// 行类名 - 使用 useCallback 避免不必要的重渲染
|
||||
const rowClassName = useCallback((record: TableRowData) => {
|
||||
return record.isImportant ? 'important-row' : '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box className="forecast-detail-table">
|
||||
<style>{tableStyles}</style>
|
||||
<style>{DETAIL_TABLE_STYLES}</style>
|
||||
<Text fontSize="md" fontWeight="bold" color="#D4AF37" mb={3}>
|
||||
详细数据表格
|
||||
</Text>
|
||||
@@ -219,4 +140,4 @@ const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailTable;
|
||||
export default memo(DetailTable);
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
* 优化:添加行业平均参考线、预测区分、置信区间
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import EChartsWrapper from '../../EChartsWrapper';
|
||||
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';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
|
||||
// 计算行业平均EPS(模拟数据,实际应从API获取)
|
||||
const industryAvgEps = useMemo(() => {
|
||||
@@ -124,9 +121,9 @@ const EpsChart: React.FC<EpsChartProps> = ({ data }) => {
|
||||
|
||||
return (
|
||||
<ChartCard title="EPS 趋势">
|
||||
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||
<EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpsChart;
|
||||
export default memo(EpsChart);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* 优化:历史/预测区分、Y轴配色对应、Tooltip格式化
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import EChartsWrapper from '../../EChartsWrapper';
|
||||
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';
|
||||
|
||||
interface IncomeProfitGrowthChartProps {
|
||||
@@ -14,9 +14,6 @@ interface IncomeProfitGrowthChartProps {
|
||||
growthData: GrowthBars;
|
||||
}
|
||||
|
||||
// 判断是否为预测年份(包含 E 后缀)
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
incomeProfitData,
|
||||
growthData,
|
||||
@@ -196,9 +193,9 @@ const IncomeProfitGrowthChart: React.FC<IncomeProfitGrowthChartProps> = ({
|
||||
|
||||
return (
|
||||
<ChartCard title="营收与利润趋势 · 增长率">
|
||||
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||
<EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncomeProfitGrowthChart;
|
||||
export default memo(IncomeProfitGrowthChart);
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
* 优化:配色区分度、线条样式、Y轴颜色对应、预测区分
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import React, { useMemo, memo } from 'react';
|
||||
import EChartsWrapper from '../../EChartsWrapper';
|
||||
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';
|
||||
|
||||
// 判断是否为预测年份
|
||||
const isForecastYear = (year: string) => year.includes('E');
|
||||
|
||||
const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||
// 找出预测数据起始索引
|
||||
const forecastStartIndex = useMemo(() => {
|
||||
@@ -145,9 +142,9 @@ const PePegChart: React.FC<PePegChartProps> = ({ data }) => {
|
||||
|
||||
return (
|
||||
<ChartCard title="PE 与 PEG 分析">
|
||||
<ReactECharts option={option} style={{ height: CHART_HEIGHT }} />
|
||||
<EChartsWrapper option={option} style={{ height: CHART_HEIGHT }} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PePegChart;
|
||||
export default memo(PePegChart);
|
||||
|
||||
@@ -92,3 +92,98 @@ export const BASE_CHART_CONFIG = {
|
||||
|
||||
// 图表高度
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useForecastData } from './useForecastData';
|
||||
export type { UseForecastDataReturn } from './useForecastData';
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 盈利预测报表视图 - 黑金主题
|
||||
* 优化:使用 useForecastData Hook、错误处理、memo 包装
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, SimpleGrid } from '@chakra-ui/react';
|
||||
import { stockService } from '@services/eventService';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, SimpleGrid, Center, VStack, Text, Button } from '@chakra-ui/react';
|
||||
import { useForecastData } from './hooks';
|
||||
import {
|
||||
IncomeProfitGrowthChart,
|
||||
EpsChart,
|
||||
@@ -12,50 +13,36 @@ import {
|
||||
DetailTable,
|
||||
} from './components';
|
||||
import LoadingState from '../LoadingState';
|
||||
import { CHART_HEIGHT } from './constants';
|
||||
import type { ForecastReportProps, ForecastData } from './types';
|
||||
import type { ForecastReportProps } from './types';
|
||||
|
||||
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCode }) => {
|
||||
const [code, setCode] = useState(propStockCode || '600000');
|
||||
const [data, setData] = useState<ForecastData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
|
||||
const { data, isLoading, error, refetch } = useForecastData(stockCode);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await stockService.getForecastReport(code);
|
||||
if (resp && resp.success) {
|
||||
setData(resp.data);
|
||||
// 加载状态
|
||||
if (isLoading && !data) {
|
||||
return <LoadingState message="加载盈利预测数据中..." height="300px" />;
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== code) {
|
||||
setCode(propStockCode);
|
||||
// 错误状态
|
||||
if (error && !data) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack spacing={3}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
<Button size="sm" colorScheme="yellow" variant="outline" onClick={refetch}>
|
||||
重试
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
}, [propStockCode, code]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
load();
|
||||
}
|
||||
}, [code, load]);
|
||||
// 无数据
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 加载状态 */}
|
||||
{loading && !data && (
|
||||
<LoadingState message="加载盈利预测数据中..." height="300px" />
|
||||
)}
|
||||
|
||||
{/* 图表区域 - 3列布局 */}
|
||||
{data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||
<IncomeProfitGrowthChart
|
||||
incomeProfitData={data.income_profit_trend}
|
||||
@@ -64,16 +51,13 @@ const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode: propStockCod
|
||||
<EpsChart data={data.eps_trend} />
|
||||
<PePegChart data={data.pe_peg_axes} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 详细数据表格 */}
|
||||
{data && (
|
||||
<Box mt={4}>
|
||||
<DetailTable data={data.detail_table} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastReport;
|
||||
export default memo(ForecastReport);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { forecastService } from './forecastService';
|
||||
export type { ForecastServiceOptions, ForecastServiceResponse } from './forecastService';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
isForecastYear,
|
||||
IMPORTANT_METRICS,
|
||||
formatTooltipValue,
|
||||
findForecastStartIndex,
|
||||
formatAmount,
|
||||
} from './chartFormatters';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
|
||||
import { dayCardStyle } from '../shared/styles';
|
||||
import type { BigDealData } from '../../types';
|
||||
|
||||
export interface BigDealPanelProps {
|
||||
@@ -32,14 +33,7 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
|
||||
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{bigDealData.daily_stats.map((dayStats, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
bg="rgba(212, 175, 55, 0.05)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
>
|
||||
<Box key={idx} sx={dayCardStyle}>
|
||||
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
|
||||
{dayStats.date}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
|
||||
// 融资融券面板 - 黑金主题
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Box, VStack, Grid } from '@chakra-ui/react';
|
||||
import ECharts from '@components/Charts/ECharts';
|
||||
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
import { getFundingDarkGoldOption } from '../../utils/chartOptions';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
import { DarkGoldCard } from '../shared';
|
||||
import { DarkGoldCard, DataRow } from '../shared';
|
||||
import { darkGoldCardFullStyle } from '../shared/styles';
|
||||
import type { FundingDayData } from '../../types';
|
||||
|
||||
@@ -24,6 +17,17 @@ export interface FundingPanelProps {
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 图表卡片 */}
|
||||
@@ -31,7 +35,7 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
||||
{fundingData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ECharts
|
||||
option={getFundingDarkGoldOption(fundingData)}
|
||||
option={chartOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
/>
|
||||
@@ -43,38 +47,14 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
||||
{/* 融资数据 */}
|
||||
<DarkGoldCard title="融资数据" titleColor={darkGoldTheme.gold}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{fundingData
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((item, idx) => (
|
||||
<Box
|
||||
{recentData.map((item, idx) => (
|
||||
<DataRow
|
||||
key={idx}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
variant="gold"
|
||||
label={item.date}
|
||||
value={formatNumber(item.financing.balance)}
|
||||
subValue={`买入${formatNumber(item.financing.buy)} / 偿还${formatNumber(item.financing.repay)}`}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</DarkGoldCard>
|
||||
@@ -82,38 +62,14 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
||||
{/* 融券数据 */}
|
||||
<DarkGoldCard title="融券数据" titleColor={darkGoldTheme.orange}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{fundingData
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((item, idx) => (
|
||||
<Box
|
||||
{recentData.map((item, idx) => (
|
||||
<DataRow
|
||||
key={idx}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
variant="orange"
|
||||
label={item.date}
|
||||
value={formatNumber(item.securities.balance)}
|
||||
subValue={`卖出${formatNumber(item.securities.sell)} / 偿还${formatNumber(item.securities.repay)}`}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</DarkGoldCard>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
|
||||
// 股权质押面板 - 黑金主题
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
@@ -28,6 +28,12 @@ export interface PledgePanelProps {
|
||||
}
|
||||
|
||||
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
||||
// 缓存图表配置
|
||||
const chartOption = useMemo(() => {
|
||||
if (pledgeData.length === 0) return {};
|
||||
return getPledgeDarkGoldOption(pledgeData);
|
||||
}, [pledgeData]);
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 图表卡片 */}
|
||||
@@ -35,7 +41,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
||||
{pledgeData.length > 0 && (
|
||||
<Box h="400px">
|
||||
<ECharts
|
||||
option={getPledgeDarkGoldOption(pledgeData)}
|
||||
option={chartOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
// 龙虎榜面板 - 黑金主题
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Grid,
|
||||
} from '@chakra-ui/react';
|
||||
import { Box, Text, VStack, HStack, Grid } from '@chakra-ui/react';
|
||||
|
||||
import { formatNumber } from '../../utils/formatUtils';
|
||||
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';
|
||||
|
||||
export interface UnusualPanelProps {
|
||||
@@ -25,14 +20,7 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
||||
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{unusualData.grouped_data.map((dayData, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
bg="rgba(212, 175, 55, 0.05)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
>
|
||||
<Box key={idx} sx={dayCardStyle}>
|
||||
<HStack justify="space-between" mb={4} flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="md" fontWeight="bold" color={darkGoldTheme.gold}>
|
||||
{dayData.date}
|
||||
@@ -58,32 +46,14 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.buyers && dayData.buyers.length > 0 ? (
|
||||
dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
<DataRow
|
||||
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}
|
||||
variant="red"
|
||||
label={buyer.dept_name}
|
||||
value={formatNumber(buyer.buy_amount)}
|
||||
isTruncated
|
||||
maxW="70%"
|
||||
>
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={darkGoldTheme.red} fontWeight="bold">
|
||||
{formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
maxLabelWidth="70%"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
@@ -100,32 +70,14 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.sellers && dayData.sellers.length > 0 ? (
|
||||
dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
<DataRow
|
||||
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}
|
||||
variant="green"
|
||||
label={seller.dept_name}
|
||||
value={formatNumber(seller.sell_amount)}
|
||||
isTruncated
|
||||
maxW="70%"
|
||||
>
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={darkGoldTheme.green} fontWeight="bold">
|
||||
{formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
maxLabelWidth="70%"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
|
||||
|
||||
@@ -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);
|
||||
@@ -4,5 +4,21 @@
|
||||
export { default as DarkGoldCard } from './DarkGoldCard';
|
||||
export { default as DarkGoldBadge } from './DarkGoldBadge';
|
||||
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 { DataRowVariant, DataRowProps } from './DataRow';
|
||||
|
||||
@@ -57,3 +57,78 @@ export const tableBorderStyle: SystemStyleObject = {
|
||||
borderBottom: '1px solid',
|
||||
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)',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user