feat(SubTabContainer): 支持自定义 Suspense fallback

- SubTabConfig 添加 fallback 属性
- 财务全景/盈利预测配置骨架屏 fallback
- 解决点击 Tab 先显示 Spinner 再显示骨架屏的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 15:23:56 +08:00
parent 6eec7c6402
commit 6776e1d557
16 changed files with 308 additions and 1916 deletions

View File

@@ -45,6 +45,8 @@ export interface SubTabConfig {
name: string; name: string;
icon?: IconType | ComponentType; icon?: IconType | ComponentType;
component?: ComponentType<any>; component?: ComponentType<any>;
/** 自定义 Suspense fallback如骨架屏 */
fallback?: React.ReactNode;
} }
/** /**
@@ -314,6 +316,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
{shouldRender && Component ? ( {shouldRender && Component ? (
<Suspense <Suspense
fallback={ fallback={
tab.fallback || (
<Center py={20}> <Center py={20}>
<Spinner <Spinner
size="lg" size="lg"
@@ -322,6 +325,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
speed="0.8s" speed="0.8s"
/> />
</Center> </Center>
)
} }
> >
<Component {...componentProps} /> <Component {...componentProps} />

View File

@@ -1,246 +0,0 @@
/**
* 资产负债表组件 - Ant Design 黑金主题
*/
import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { formatUtils } from '@services/financialService';
import {
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
} from '../constants';
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { BalanceSheetTableProps, MetricConfig } from '../types';
const TABLE_CLASS_NAME = 'balance-sheet-table';
const tableStyles = getTableStyles(TABLE_CLASS_NAME);
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
const BalanceSheetTableInner: React.FC<BalanceSheetTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 所有分类配置
const allSections = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
allSections.forEach((section) => {
// 添加分组标题行(汇总行不显示标题)
if (!['资产总计', '负债合计'].includes(section.title)) {
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || ['资产总计', '负债合计'].includes(section.title),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化(使用共享函数)
const calcYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calcYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 80,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Button
size="xs"
variant="outline"
color="#D4AF37"
borderColor="#D4AF37"
_hover={{ bg: 'rgba(212, 175, 55, 0.2)' }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
>
</Button>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="balance-sheet-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export const BalanceSheetTable = memo(BalanceSheetTableInner);
export default BalanceSheetTable;

View File

@@ -1,199 +0,0 @@
/**
* 现金流量表组件 - Ant Design 黑金主题
*/
import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { formatUtils } from '@services/financialService';
import { CASHFLOW_METRICS } from '../constants';
import { getValueByPath, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { CashflowTableProps } from '../types';
const TABLE_CLASS_NAME = 'cashflow-table';
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
.${TABLE_CLASS_NAME} .positive-value {
color: #E53E3E;
}
.${TABLE_CLASS_NAME} .negative-value {
color: #48BB78;
}
`;
// 核心指标
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
const CashflowTableInner: React.FC<CashflowTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 8);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData = useMemo(() => {
return CASHFLOW_METRICS.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: CORE_METRICS.includes(metric.key),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData]);
// 计算同比变化(使用共享函数)
const calcYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calcYoY(value, item.period, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 1);
const isNegative = value !== undefined && value < 0;
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text className={isNegative ? 'negative-value' : 'positive-value'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 50 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 80,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Button
size="xs"
variant="outline"
color="#D4AF37"
borderColor="#D4AF37"
_hover={{ bg: 'rgba(212, 175, 55, 0.2)' }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
>
</Button>
),
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="cashflow-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export const CashflowTable = memo(CashflowTableInner);
export default CashflowTable;

View File

@@ -1,360 +0,0 @@
/**
* 财务指标表格组件 - Ant Design 黑金主题
*/
import React, { useState, useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, SimpleGrid, Card, CardBody, CardHeader, Heading, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { FinancialMetricsTableProps } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.financial-metrics-table .ant-table {
background: transparent !important;
}
.financial-metrics-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;
}
.financial-metrics-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-metrics-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-metrics-table .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-metrics-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-metrics-table .positive-change {
color: #E53E3E;
}
.financial-metrics-table .negative-change {
color: #48BB78;
}
.financial-metrics-table .positive-value {
color: #E53E3E;
}
.financial-metrics-table .negative-value {
color: #48BB78;
}
.financial-metrics-table .ant-table-placeholder {
background: transparent !important;
}
.financial-metrics-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
}) => {
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
// 构建表格数据
const tableData = useMemo(() => {
return currentCategory.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [data, displayData, currentCategory]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: currentCategory.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = selectedCategory === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, data, showMetricChart, currentCategory, selectedCategory]);
return (
<Box>
{/* 分类选择器 */}
<HStack spacing={2} mb={4} flexWrap="wrap">
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).map(
([key, category]) => (
<Button
key={key}
size="sm"
variant={selectedCategory === key ? 'solid' : 'outline'}
bg={selectedCategory === key ? 'rgba(212, 175, 55, 0.3)' : 'transparent'}
color={selectedCategory === key ? '#D4AF37' : 'gray.400'}
borderColor="rgba(212, 175, 55, 0.3)"
_hover={{
bg: 'rgba(212, 175, 55, 0.2)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
onClick={() => setSelectedCategory(key)}
>
{category.title.replace('指标', '')}
</Button>
)
)}
</HStack>
{/* 指标表格 */}
<Box className="financial-metrics-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, data, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
{/* 关键指标快速对比 */}
{data[0] && (
<Card mt={4} bg="transparent" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardHeader py={3} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Heading size="sm" color="#D4AF37"></Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{[
{
label: 'ROE',
value: getValueByPath<number>(data[0], 'profitability.roe'),
format: 'percent',
},
{
label: '毛利率',
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
format: 'percent',
},
{
label: '净利率',
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
format: 'percent',
},
{
label: '流动比率',
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
format: 'decimal',
},
{
label: '资产负债率',
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
format: 'percent',
},
{
label: '研发费用率',
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
format: 'percent',
},
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg="rgba(212, 175, 55, 0.1)" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<Text fontSize="xs" color="gray.400">
{item.label}
</Text>
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
{item.format === 'percent'
? formatUtils.formatPercent(item.value)
: item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
)}
</Box>
);
};
export default FinancialMetricsTable;

View File

@@ -1,328 +0,0 @@
/**
* 通用财务表格组件 - Ant Design 黑金主题
*/
import React from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip, Badge } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
// Ant Design 表格黑金主题配置
export const FINANCIAL_TABLE_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 通用样式
export const tableStyles = `
.financial-table .ant-table {
background: transparent !important;
}
.financial-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;
}
.financial-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.financial-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.financial-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.financial-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.financial-table .ant-table-cell-fix-left,
.financial-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.financial-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.financial-table .positive-change {
color: #E53E3E;
}
.financial-table .negative-change {
color: #48BB78;
}
.financial-table .ant-table-placeholder {
background: transparent !important;
}
.financial-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 指标类型
export interface MetricConfig {
name: string;
key: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
}
export interface MetricSectionConfig {
title: string;
key: string;
metrics: MetricConfig[];
}
// 表格行数据类型
export interface FinancialTableRow {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
// 组件 Props
export interface FinancialTableProps {
data: Array<{ period: string; [key: string]: unknown }>;
sections: MetricSectionConfig[];
onRowClick?: (name: string, key: string, path: string) => void;
loading?: boolean;
maxColumns?: number;
}
// 获取嵌套路径的值
const getValueByPath = (obj: Record<string, unknown>, path: string): number | undefined => {
const keys = path.split('.');
let value: unknown = obj;
for (const key of keys) {
if (value && typeof value === 'object') {
value = (value as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return typeof value === 'number' ? value : undefined;
};
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
data: Array<{ period: string; [key: string]: unknown }>,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = data.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath(lastYearPeriod as Record<string, unknown>, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
const FinancialTable: React.FC<FinancialTableProps> = ({
data,
sections,
onRowClick,
loading = false,
maxColumns = 6,
}) => {
// 限制显示列数
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData: FinancialTableRow[] = [];
sections.forEach((section) => {
// 添加分组标题行(除了汇总行)
if (!section.title.includes('总计') && !section.title.includes('合计')) {
tableData.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
}
// 添加指标行
section.metrics.forEach((metric) => {
const row: FinancialTableRow = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal || section.title.includes('总计') || section.title.includes('合计'),
indent: metric.isTotal ? 0 : 1,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath(item as Record<string, unknown>, metric.path);
row[item.period] = value;
});
tableData.push(row);
});
});
// 构建列定义
const columns: ColumnsType<FinancialTableRow> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 180,
render: (name: string, record: FinancialTableRow) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? 4 : 0}>
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 110,
align: 'right' as const,
render: (value: number | undefined, record: FinancialTableRow) => {
if (record.isSection) return null;
const yoy = calculateYoY(value, item.period, data, record.path);
const formattedValue = formatUtils.formatLargeNumber(value, 0);
return (
<Tooltip
title={
<Box>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontWeight={record.isTotal ? 'bold' : 'normal'}>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={yoy > 0 ? 'positive-change' : 'negative-change'}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: FinancialTableRow) => {
if (record.isSection) return null;
return (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
onRowClick?.(record.name, record.key, record.path);
}}
/>
);
},
},
];
return (
<Box className="financial-table">
<style>{tableStyles}</style>
<ConfigProvider theme={FINANCIAL_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
loading={loading}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection && onRowClick) {
onRowClick(record.name, record.key, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export default FinancialTable;

View File

@@ -1,247 +0,0 @@
/**
* 利润表组件 - Ant Design 黑金主题
*/
import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Button } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { formatUtils } from '@services/financialService';
import { INCOME_STATEMENT_SECTIONS } from '../constants';
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { IncomeStatementTableProps, MetricConfig } from '../types';
const TABLE_CLASS_NAME = 'income-statement-table';
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
.${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
background: rgba(212, 175, 55, 0.1) !important;
font-weight: 500;
}
.${TABLE_CLASS_NAME} .negative-value {
color: #E53E3E;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
const IncomeStatementTableInner: React.FC<IncomeStatementTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
INCOME_STATEMENT_SECTIONS.forEach((section) => {
// 添加分组标题行
rows.push({
key: `section-${section.key}`,
name: section.title,
path: '',
isSection: true,
});
// 添加指标行
section.metrics.forEach((metric: MetricConfig) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
isTotal: metric.isTotal,
isSubtotal: metric.isSubtotal,
indent: metric.isTotal || metric.isSubtotal ? 0 : (metric.name.startsWith(' ') ? 2 : 1),
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
rows.push(row);
});
});
return rows;
}, [data, displayData]);
// 计算同比变化(使用共享函数)
const calcYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: '项目',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 250,
render: (name: string, record: TableRowData) => {
if (record.isSection) {
return <Text fontWeight="bold" color="#D4AF37">{name}</Text>;
}
return (
<HStack spacing={2} pl={record.indent ? record.indent * 4 : 0}>
<Text fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}>{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
);
},
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 120,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
if (record.isSection) return null;
const yoy = calcYoY(value, item.period, record.path);
const isEPS = record.key.includes('eps');
const formattedValue = isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value, 0);
const isNegative = value !== undefined && value < 0;
// 成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = isNegativeIndicator(record.key);
const changeColor = isCostItem
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
return (
<Tooltip
title={
<Box>
<Text>: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
className={isNegative ? 'negative-value' : undefined}
>
{formattedValue}
</Text>
{yoy !== null && Math.abs(yoy) > 30 && !record.isTotal && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 80,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<Button
size="xs"
variant="outline"
color="#D4AF37"
borderColor="#D4AF37"
_hover={{ bg: 'rgba(212, 175, 55, 0.2)' }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, data, record.path);
}}
>
</Button>
);
},
},
];
return cols;
}, [displayData, data, showMetricChart]);
return (
<Box className="income-statement-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
rowClassName={(record) => {
if (record.isSection) return 'section-header';
if (record.isTotal) return 'total-row';
if (record.isSubtotal) return 'subtotal-row';
return '';
}}
onRow={(record) => ({
onClick: () => {
if (!record.isSection) {
showMetricChart(record.name, record.key, data, record.path);
}
},
style: { cursor: record.isSection ? 'default' : 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};
export const IncomeStatementTable = memo(IncomeStatementTableInner);
export default IncomeStatementTable;

View File

@@ -4,13 +4,8 @@
export { PeriodSelector } from './PeriodSelector'; export { PeriodSelector } from './PeriodSelector';
export { FinancialOverviewPanel } from './FinancialOverviewPanel'; export { FinancialOverviewPanel } from './FinancialOverviewPanel';
// 保留旧组件导出(向后兼容)
export { KeyMetricsOverview } from './KeyMetricsOverview'; export { KeyMetricsOverview } from './KeyMetricsOverview';
export { StockInfoHeader } from './StockInfoHeader'; export { StockInfoHeader } from './StockInfoHeader';
export { BalanceSheetTable } from './BalanceSheetTable';
export { IncomeStatementTable } from './IncomeStatementTable';
export { CashflowTable } from './CashflowTable';
export { FinancialMetricsTable } from './FinancialMetricsTable';
export { MainBusinessAnalysis } from './MainBusinessAnalysis'; export { MainBusinessAnalysis } from './MainBusinessAnalysis';
export { IndustryRankingView } from './IndustryRankingView'; export { IndustryRankingView } from './IndustryRankingView';
export { StockComparison } from './StockComparison'; export { StockComparison } from './StockComparison';
@@ -18,3 +13,6 @@ export { ComparisonAnalysis } from './ComparisonAnalysis';
export { MetricChartModal } from './MetricChartModal'; export { MetricChartModal } from './MetricChartModal';
export type { MetricChartModalProps } from './MetricChartModal'; export type { MetricChartModalProps } from './MetricChartModal';
export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton'; export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
// 统一财务表格组件
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';

View File

@@ -1,60 +0,0 @@
/**
* 资产负债表 Tab
*/
import React, { memo } from 'react';
import { Box, Spinner, Center } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types';
export interface BalanceSheetTabProps {
balanceSheet: BalanceSheetData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const BalanceSheetTabInner: React.FC<BalanceSheetTabProps> = ({
balanceSheet,
loading,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
// 加载中状态
if (loading && (!Array.isArray(balanceSheet) || balanceSheet.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</Box>
);
};
const BalanceSheetTab = memo(BalanceSheetTabInner);
export default BalanceSheetTab;

View File

@@ -1,60 +0,0 @@
/**
* 现金流量表 Tab
*/
import React, { memo } from 'react';
import { Box, Spinner, Center } from '@chakra-ui/react';
import { CashflowTable } from '../components';
import type { CashflowData } from '../types';
export interface CashflowTabProps {
cashflow: CashflowData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const CashflowTabInner: React.FC<CashflowTabProps> = ({
cashflow,
loading,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
// 加载中状态
if (loading && (!Array.isArray(cashflow) || cashflow.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<CashflowTable data={cashflow} {...tableProps} />
</Box>
);
};
const CashflowTab = memo(CashflowTabInner);
export default CashflowTab;

View File

@@ -1,58 +0,0 @@
/**
* 财务指标 Tab
*/
import React, { memo } from 'react';
import { Spinner, Center } from '@chakra-ui/react';
import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types';
export interface FinancialMetricsTabProps {
financialMetrics: FinancialMetricsData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const FinancialMetricsTabInner: React.FC<FinancialMetricsTabProps> = ({
financialMetrics,
loading,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
// 加载中状态
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
);
};
const FinancialMetricsTab = memo(FinancialMetricsTabInner);
export default FinancialMetricsTab;

View File

@@ -1,60 +0,0 @@
/**
* 利润表 Tab
*/
import React, { memo } from 'react';
import { Box, Spinner, Center } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types';
export interface IncomeStatementTabProps {
incomeStatement: IncomeStatementData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
const IncomeStatementTabInner: React.FC<IncomeStatementTabProps> = ({
incomeStatement,
loading,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
// 加载中状态
if (loading && (!Array.isArray(incomeStatement) || incomeStatement.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<Box>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</Box>
);
};
const IncomeStatementTab = memo(IncomeStatementTabInner);
export default IncomeStatementTab;

View File

@@ -1,269 +0,0 @@
/**
* 财务指标分类 Tab - Ant Design 黑金主题
* 接受 categoryKey 显示单个分类的指标表格
*/
import React, { useMemo, memo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge, Spinner, Center } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator, BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY } from '../utils';
import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
const TABLE_CLASS_NAME = 'metrics-category-table';
const tableStyles = getTableStyles(TABLE_CLASS_NAME) + `
.${TABLE_CLASS_NAME} .positive-value {
color: #E53E3E;
}
.${TABLE_CLASS_NAME} .negative-value {
color: #48BB78;
}
`;
export interface MetricsCategoryTabProps {
categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
const MetricsCategoryTabInner: React.FC<MetricsCategoryTabProps> = ({
categoryKey,
financialMetrics,
loading,
showMetricChart,
calculateYoYChange,
}) => {
// 加载中状态
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
if (!category) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
// 构建表格数据
const tableData = useMemo(() => {
return category.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [financialMetrics, displayData, category]);
// 计算同比变化(使用共享函数)
const calcYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
return calculateYoY(financialMetrics, currentValue, currentPeriod, path, getValueByPath);
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: category.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calcYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = categoryKey === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, financialMetrics, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
return (
<Box>
<Box className="metrics-category-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, financialMetrics, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
</Box>
);
};
const MetricsCategoryTab = memo(MetricsCategoryTabInner);
// 为每个分类创建预配置的组件(使用 memo
export const ProfitabilityTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} />
));
export const PerShareTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} />
));
export const GrowthTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="growth" {...props} />
));
export const OperationalTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="operational" {...props} />
));
export const SolvencyTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} />
));
export const ExpenseTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="expense" {...props} />
));
export const CashflowMetricsTab = memo<Omit<MetricsCategoryTabProps, 'categoryKey'>>((props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} />
));
export default MetricsCategoryTab;

View File

@@ -0,0 +1,264 @@
/**
* 统一的财务 Tab 组件
*
* 使用 UnifiedFinancialTable 实现所有 10 个财务表格:
* - 7 个财务指标分类 Tab
* - 3 个财务报表 Tab
*/
import React, { memo } from 'react';
import { UnifiedFinancialTable, type FinancialDataItem } from '../components/UnifiedFinancialTable';
import {
FINANCIAL_METRICS_CATEGORIES,
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
INCOME_STATEMENT_SECTIONS,
CASHFLOW_METRICS,
} from '../constants';
import type { FinancialMetricsData, BalanceSheetData, IncomeStatementData, CashflowData } from '../types';
// ==================== 通用 Props 类型 ====================
/** 财务指标 Tab Props */
export interface MetricsTabProps {
financialMetrics: FinancialMetricsData[];
loading?: boolean;
loadingTab?: string | null;
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
/** 资产负债表 Tab Props */
export interface BalanceSheetTabProps {
balanceSheet: BalanceSheetData[];
loading?: boolean;
loadingTab?: string | null;
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
/** 利润表 Tab Props */
export interface IncomeStatementTabProps {
incomeStatement: IncomeStatementData[];
loading?: boolean;
loadingTab?: string | null;
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
/** 现金流量表 Tab Props */
export interface CashflowTabProps {
cashflow: CashflowData[];
loading?: boolean;
loadingTab?: string | null;
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
// ==================== 财务指标 Tab (7个) ====================
/** 盈利能力 Tab */
export const ProfitabilityTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.profitability;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="profitability"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
ProfitabilityTab.displayName = 'ProfitabilityTab';
/** 每股指标 Tab */
export const PerShareTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.perShare;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="perShare"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
PerShareTab.displayName = 'PerShareTab';
/** 成长能力 Tab */
export const GrowthTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.growth;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="growth"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
isGrowthCategory
/>
);
});
GrowthTab.displayName = 'GrowthTab';
/** 运营效率 Tab */
export const OperationalTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.operational;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="operational"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
OperationalTab.displayName = 'OperationalTab';
/** 偿债能力 Tab */
export const SolvencyTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.solvency;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="solvency"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
SolvencyTab.displayName = 'SolvencyTab';
/** 费用率 Tab */
export const ExpenseTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.expense;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="expense"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
ExpenseTab.displayName = 'ExpenseTab';
/** 现金流指标 Tab */
export const CashflowMetricsTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
const category = FINANCIAL_METRICS_CATEGORIES.cashflow;
return (
<UnifiedFinancialTable
type="metrics"
data={financialMetrics as unknown as FinancialDataItem[]}
categoryKey="cashflow"
categoryTitle={category.title}
metrics={category.metrics}
showMetricChart={showMetricChart}
loading={loading}
/>
);
});
CashflowMetricsTab.displayName = 'CashflowMetricsTab';
// ==================== 财务报表 Tab (3个) ====================
// 资产负债表分组配置
const BALANCE_SHEET_SECTIONS = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
/** 资产负债表 Tab */
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, showMetricChart }) => (
<UnifiedFinancialTable
type="statement"
data={balanceSheet as unknown as FinancialDataItem[]}
sections={BALANCE_SHEET_SECTIONS}
showMetricChart={showMetricChart}
loading={loading}
/>
));
BalanceSheetTab.displayName = 'BalanceSheetTab';
/** 利润表 Tab */
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, showMetricChart }) => (
<UnifiedFinancialTable
type="statement"
data={incomeStatement as unknown as FinancialDataItem[]}
sections={INCOME_STATEMENT_SECTIONS}
hideTotalSectionTitle={false}
showMetricChart={showMetricChart}
loading={loading}
/>
));
IncomeStatementTab.displayName = 'IncomeStatementTab';
// 现金流量表配置(转换为 sections 格式)
const CASHFLOW_SECTIONS = [{
title: '现金流量',
key: 'cashflow',
metrics: CASHFLOW_METRICS.map(m => ({
...m,
isCore: ['operating_net', 'free_cash_flow'].includes(m.key),
})),
}];
/** 现金流量表 Tab */
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, showMetricChart }) => (
<UnifiedFinancialTable
type="statement"
data={cashflow as unknown as FinancialDataItem[]}
sections={CASHFLOW_SECTIONS}
hideTotalSectionTitle
showMetricChart={showMetricChart}
loading={loading}
/>
));
CashflowTab.displayName = 'CashflowTab';

View File

@@ -1,14 +1,12 @@
/** /**
* Tab 组件统一导出 * Tab 组件统一导出
*
* 使用 UnifiedTabs 实现的 10 个财务表格 Tab
*/ */
// 三大财务报表 // 统一 Tab 组件导出
export { default as BalanceSheetTab } from './BalanceSheetTab';
export { default as IncomeStatementTab } from './IncomeStatementTab';
export { default as CashflowTab } from './CashflowTab';
// 财务指标分类 tabs
export { export {
// 7 个财务指标 Tab
ProfitabilityTab, ProfitabilityTab,
PerShareTab, PerShareTab,
GrowthTab, GrowthTab,
@@ -16,13 +14,20 @@ export {
SolvencyTab, SolvencyTab,
ExpenseTab, ExpenseTab,
CashflowMetricsTab, CashflowMetricsTab,
} from './MetricsCategoryTab'; // 3 个财务报表 Tab
BalanceSheetTab,
IncomeStatementTab,
CashflowTab,
} from './UnifiedTabs';
// 旧的综合财务指标 tab保留兼容 // 类型导出
export { default as FinancialMetricsTab } from './FinancialMetricsTab'; export type {
MetricsTabProps,
BalanceSheetTabProps,
IncomeStatementTabProps,
CashflowTabProps,
} from './UnifiedTabs';
export type { BalanceSheetTabProps } from './BalanceSheetTab'; // 兼容旧的类型别名
export type { IncomeStatementTabProps } from './IncomeStatementTab'; export type { MetricsTabProps as MetricsCategoryTabProps } from './UnifiedTabs';
export type { CashflowTabProps } from './CashflowTab'; export type { MetricsTabProps as FinancialMetricsTabProps } from './UnifiedTabs';
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';

View File

@@ -4,10 +4,14 @@
* - Tab 配置 * - Tab 配置
*/ */
import { lazy } from 'react'; import React, { lazy } from 'react';
import { Building2, Brain, TrendingUp, Wallet, FileBarChart, Newspaper } from 'lucide-react'; import { Building2, Brain, TrendingUp, Wallet, FileBarChart, Newspaper } from 'lucide-react';
import type { CompanyTheme, TabConfig } from './types'; import type { CompanyTheme, TabConfig } from './types';
// 骨架屏组件(同步导入,用于 Suspense fallback
import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components';
import { ForecastSkeleton } from './components/ForecastReport/components';
// ============================================ // ============================================
// 黑金主题配置 // 黑金主题配置
// ============================================ // ============================================
@@ -89,12 +93,14 @@ export const TAB_CONFIG: TabConfig[] = [
name: '财务全景', name: '财务全景',
icon: Wallet, icon: Wallet,
component: FinancialPanorama, component: FinancialPanorama,
fallback: React.createElement(FinancialPanoramaSkeleton),
}, },
{ {
key: 'forecast', key: 'forecast',
name: '盈利预测', name: '盈利预测',
icon: FileBarChart, icon: FileBarChart,
component: ForecastReport, component: ForecastReport,
fallback: React.createElement(ForecastSkeleton),
}, },
{ {
key: 'tracking', key: 'tracking',

View File

@@ -2,7 +2,7 @@
* Company 页面类型定义 * Company 页面类型定义
*/ */
import type { ComponentType } from 'react'; import type { ComponentType, ReactNode } from 'react';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
@@ -36,6 +36,8 @@ export interface TabConfig {
name: string; name: string;
icon: LucideIcon | IconType | ComponentType; icon: LucideIcon | IconType | ComponentType;
component: ComponentType<TabComponentProps>; component: ComponentType<TabComponentProps>;
/** 自定义 Suspense fallback如骨架屏 */
fallback?: ReactNode;
} }
export interface TabComponentProps { export interface TabComponentProps {