Compare commits
10 Commits
4e71623477
...
2cc16be585
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc16be585 | ||
|
|
11ca0e7a99 | ||
|
|
ff951972ee | ||
|
|
41da6fa372 | ||
|
|
54cce55c29 | ||
|
|
0e29f1aff4 | ||
|
|
7b58f83490 | ||
|
|
22062a6556 | ||
|
|
94854fee3e | ||
|
|
852d5fd188 |
@@ -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 实现懒加载
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ export {
|
|||||||
getMainBusinessPieOption,
|
getMainBusinessPieOption,
|
||||||
getCompareBarChartOption,
|
getCompareBarChartOption,
|
||||||
} from './chartOptions';
|
} 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 { 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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';
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user