refactor(FinancialPanorama): 重构为 7+3 Tab 架构

- 财务指标拆分为 7 个分类 Tab(盈利/每股/成长/运营/偿债/费用/现金流)
- 保留 3 大报表 Tab(资产负债表/利润表/现金流量表)
- 新增 KeyMetricsOverview 关键指标速览组件
- 新增 FinancialTable 通用表格组件
- Hook 支持按 Tab 独立刷新数据
- PeriodSelector 整合到 SubTabContainer 右侧
- 删除废弃的 OverviewTab/MainBusinessTab

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-16 19:59:16 +08:00
parent 42215b2d59
commit bc6d370f55
20 changed files with 2414 additions and 1082 deletions

View File

@@ -1,27 +1,12 @@
/**
* 资产负债表组件
* 资产负债表组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Box,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import {
CURRENT_ASSETS_METRICS,
@@ -33,221 +18,308 @@ import {
EQUITY_METRICS,
} from '../constants';
import { getValueByPath } from '../utils';
import type { BalanceSheetTableProps, MetricSectionConfig } from '../types';
import type { BalanceSheetTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.balance-sheet-table .ant-table {
background: transparent !important;
}
.balance-sheet-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.balance-sheet-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.balance-sheet-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.balance-sheet-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.balance-sheet-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.balance-sheet-table .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.balance-sheet-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.balance-sheet-table .positive-change {
color: #E53E3E;
}
.balance-sheet-table .negative-change {
color: #48BB78;
}
.balance-sheet-table .ant-table-placeholder {
background: transparent !important;
}
.balance-sheet-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
currentAssets: true,
nonCurrentAssets: true,
currentLiabilities: true,
nonCurrentLiabilities: true,
equity: true,
});
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
// 资产部分配置
const assetSections: MetricSectionConfig[] = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
];
// 负债部分配置
const liabilitySections: MetricSectionConfig[] = [
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
];
// 权益部分配置
const equitySections: MetricSectionConfig[] = [EQUITY_METRICS];
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const renderSection = (sections: MetricSectionConfig[]) => (
<>
{sections.map((section) => (
<React.Fragment key={section.key}>
{section.title !== '资产总计' &&
section.title !== '负债合计' && (
<Tr
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? (
<ChevronUpIcon />
) : (
<ChevronDownIcon />
)}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
)}
{(expandedSections[section.key] ||
section.title === '资产总计' ||
section.title === '负债合计' ||
section.title === '股东权益合计') &&
section.metrics.map((metric) => {
const rowData = data.map((item) =>
getValueByPath<number>(item, metric.path)
);
// 所有分类配置
const allSections = [
CURRENT_ASSETS_METRICS,
NON_CURRENT_ASSETS_METRICS,
TOTAL_ASSETS_METRICS,
CURRENT_LIABILITIES_METRICS,
NON_CURRENT_LIABILITIES_METRICS,
TOTAL_LIABILITIES_METRICS,
EQUITY_METRICS,
];
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() =>
showMetricChart(metric.name, metric.key, data, metric.path)
}
bg={metric.isTotal ? 'blue.50' : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal && <Box w={4} />}
<Text
fontWeight={metric.isTotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
>
{metric.name}
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 构建表格数据
const tableData = useMemo(() => {
const rows: TableRowData[] = [];
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
: {formatUtils.formatLargeNumber(value)}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
fontWeight={metric.isTotal ? 'bold' : 'normal'}
>
{formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看图表"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, data, metric.path);
}}
/>
</Td>
</Tr>
);
})}
</React.Fragment>
))}
</>
);
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 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: '项目',
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 = calculateYoY(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: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<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]);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{renderSection(assetSections)}
<Tr height={2} />
{renderSection(liabilitySections)}
<Tr height={2} />
{renderSection(equitySections)}
</Tbody>
</Table>
</TableContainer>
<Box className="balance-sheet-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_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>
);
};

View File

@@ -1,156 +1,268 @@
/**
* 现金流量表组件
* 现金流量表组件 - Ant Design 黑金主题
*/
import React from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { CASHFLOW_METRICS } from '../constants';
import { getValueByPath } from '../utils';
import type { CashflowTableProps } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.cashflow-table .ant-table {
background: transparent !important;
}
.cashflow-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.cashflow-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.cashflow-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.cashflow-table .ant-table-cell-fix-left,
.cashflow-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.cashflow-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.cashflow-table .positive-value {
color: #E53E3E;
}
.cashflow-table .negative-value {
color: #48BB78;
}
.cashflow-table .positive-change {
color: #E53E3E;
}
.cashflow-table .negative-change {
color: #48BB78;
}
.cashflow-table .ant-table-placeholder {
background: transparent !important;
}
.cashflow-table .ant-empty-description {
color: #A0AEC0;
}
`;
// 核心指标
const CORE_METRICS = ['operating_net', 'free_cash_flow'];
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
export const CashflowTable: React.FC<CashflowTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 8);
const displayData = data.slice(0, maxColumns);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1}>
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
// 构建表格数据
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 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: '项目',
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 = calculateYoY(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>
</VStack>
</Th>
))}
<Th></Th>
</Tr>
</Thead>
<Tbody>
{CASHFLOW_METRICS.map((metric) => {
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
)}
</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 (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack>
<Text fontWeight="medium">{metric.name}</Text>
{['operating_net', 'free_cash_flow'].includes(metric.key) && (
<Badge colorScheme="purple"></Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const isNegative = value !== undefined && value < 0;
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
return cols;
}, [displayData, data, showMetricChart]);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>: {formatUtils.formatLargeNumber(value)}</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={isNegative ? negativeColor : positiveColor}
>
{formatUtils.formatLargeNumber(value, 1)}
</Text>
</Tooltip>
{Math.abs(change) > 50 && (
<Text
position="absolute"
top="0"
right="1"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看趋势"
/>
</Td>
</Tr>
);
return (
<Box className="cashflow-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' },
})}
</Tbody>
</Table>
</TableContainer>
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
);
};

View File

@@ -1,33 +1,12 @@
/**
* 财务指标表格组件
* 财务指标表格组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
VStack,
HStack,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Heading,
SimpleGrid,
Box,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
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';
@@ -35,25 +14,96 @@ 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,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
@@ -61,172 +111,202 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
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 (
<VStack spacing={4} align="stretch">
<Box>
{/* 分类选择器 */}
<HStack spacing={2} wrap="wrap">
<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'}
colorScheme="blue"
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}
{category.title.replace('指标', '')}
</Button>
)
)}
</HStack>
{/* 指标表格 */}
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
{currentCategory.title}
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="100px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{currentCategory.metrics.map((metric) => {
const rowData = data.map((item) =>
getValueByPath<number>(item, metric.path)
);
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() =>
showMetricChart(metric.name, metric.key, data, metric.path)
}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">
{metric.name}
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 判断指标性质
const isNegative = isNegativeIndicator(metric.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const displayColor = isNegative
? change > 0
? negativeColor
: positiveColor
: change > 0
? positiveColor
: negativeColor;
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity * 0.3)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
{metric.name}: {value?.toFixed(2) || '-'}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={
selectedCategory === 'growth'
? value !== undefined && value > 0
? positiveColor
: value !== undefined && value < 0
? negativeColor
: 'gray.500'
: 'inherit'
}
>
{value?.toFixed(2) || '-'}
</Text>
</Tooltip>
{Math.abs(change) > 20 &&
value !== undefined &&
Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看趋势"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, data, metric.path);
}}
/>
</Td>
</Tr>
);
<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' },
})}
</Tbody>
</Table>
</TableContainer>
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
{/* 关键指标快速对比 */}
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{data[0] &&
[
{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'),
@@ -258,21 +338,22 @@ export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
format: 'percent',
},
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
<Text fontSize="xs" color="gray.500">
<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">
<Text fontSize="lg" fontWeight="bold" color="#D4AF37">
{item.format === 'percent'
? formatUtils.formatPercent(item.value)
: item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
</VStack>
</SimpleGrid>
</CardBody>
</Card>
)}
</Box>
);
};

View File

@@ -0,0 +1,328 @@
/**
* 通用财务表格组件 - 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,228 +1,325 @@
/**
* 利润表组件
* 利润表组件 - Ant Design 黑金主题
*/
import React, { useState } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Text,
VStack,
HStack,
Box,
Badge,
Tooltip,
IconButton,
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { INCOME_STATEMENT_SECTIONS } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { IncomeStatementTableProps } from '../types';
import type { IncomeStatementTableProps, MetricConfig } from '../types';
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.income-statement-table .ant-table {
background: transparent !important;
}
.income-statement-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.income-statement-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.income-statement-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.income-statement-table .ant-table-tbody > tr.total-row > td {
background: rgba(212, 175, 55, 0.15) !important;
font-weight: 600;
}
.income-statement-table .ant-table-tbody > tr.subtotal-row > td {
background: rgba(212, 175, 55, 0.1) !important;
font-weight: 500;
}
.income-statement-table .ant-table-tbody > tr.section-header > td {
background: rgba(212, 175, 55, 0.08) !important;
font-weight: 600;
color: #D4AF37;
}
.income-statement-table .ant-table-cell-fix-left,
.income-statement-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.income-statement-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.income-statement-table .positive-change {
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;
}
`;
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
isTotal?: boolean;
isSubtotal?: boolean;
isSection?: boolean;
indent?: number;
[period: string]: unknown;
}
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
data,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
positiveColor = 'red.500',
negativeColor = 'green.500',
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
revenue: true,
costs: true,
otherGains: true,
profits: true,
eps: true,
comprehensive: true,
});
const toggleSection = (section: string) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
// 数组安全检查
if (!Array.isArray(data) || data.length === 0) {
return (
<Alert status="info">
<AlertIcon />
<Box p={4} textAlign="center" color="gray.400">
</Alert>
</Box>
);
}
const maxColumns = Math.min(data.length, 6);
const displayData = data.slice(0, maxColumns);
const renderSection = (section: (typeof INCOME_STATEMENT_SECTIONS)[0]) => (
<React.Fragment key={section.key}>
<Tr
bg="gray.50"
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? <ChevronUpIcon /> : <ChevronDownIcon />}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
{expandedSections[section.key] &&
section.metrics.map((metric) => {
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
// 构建表格数据
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 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: '项目',
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 = calculateYoY(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 (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
bg={
metric.isTotal
? 'blue.50'
: metric.isSubtotal
? 'orange.50'
: 'transparent'
<Tooltip
title={
<Box>
<Text>: {isEPS ? value?.toFixed(3) : formatUtils.formatLargeNumber(value)}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal &&
!metric.isSubtotal && (
<Box w={metric.name.startsWith(' ') ? 8 : 4} />
)}
<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
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{metric.name}
{yoy > 0 ? '↑' : '↓'}{Math.abs(yoy).toFixed(0)}%
</Text>
{metric.isCore && (
<Badge size="xs" colorScheme="purple">
</Badge>
)}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value ?? 0,
item.period,
data,
metric.path
);
// 特殊处理:成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = isNegativeIndicator(metric.key);
const displayColor = isCostItem
? change > 0
? negativeColor
: positiveColor
: change > 0
? positiveColor
: negativeColor;
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
:{' '}
{metric.key.includes('eps')
? value?.toFixed(3)
: formatUtils.formatLargeNumber(value)}
</Text>
<Text>: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'normal'}
color={value !== undefined && value < 0 ? 'red.500' : 'inherit'}
>
{metric.key.includes('eps')
? value?.toFixed(3)
: formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
aria-label="查看图表"
/>
</Td>
</Tr>
)}
</Box>
</Tooltip>
);
})}
</React.Fragment>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => {
if (record.isSection) return null;
return (
<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]);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="250px">
</Th>
{displayData.map((item) => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px"></Th>
</Tr>
</Thead>
<Tbody>
{INCOME_STATEMENT_SECTIONS.map((section) => renderSection(section))}
</Tbody>
</Table>
</TableContainer>
<Box className="income-statement-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_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>
);
};

View File

@@ -0,0 +1,138 @@
/**
* 关键指标速览组件 - 黑金主题
* 展示核心财务指标的快速概览
*/
import React, { memo } from 'react';
import { Box, Heading, SimpleGrid, Text, HStack, Icon } from '@chakra-ui/react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import type { FinancialMetricsData } from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
itemBg: 'rgba(212, 175, 55, 0.05)',
itemBorder: 'rgba(212, 175, 55, 0.15)',
labelColor: 'gray.400',
valueColor: 'white',
positiveColor: '#22c55e',
negativeColor: '#ef4444',
};
// 指标配置
const KEY_METRICS = [
{ label: 'ROE', path: 'profitability.roe', format: 'percent', higherBetter: true },
{ label: '毛利率', path: 'profitability.gross_margin', format: 'percent', higherBetter: true },
{ label: '净利率', path: 'profitability.net_profit_margin', format: 'percent', higherBetter: true },
{ label: '流动比率', path: 'solvency.current_ratio', format: 'decimal', higherBetter: true },
{ label: '资产负债率', path: 'solvency.asset_liability_ratio', format: 'percent', higherBetter: false },
{ label: '研发费用率', path: 'expense_ratios.rd_expense_ratio', format: 'percent', higherBetter: true },
];
// 通过路径获取值
const getValueByPath = <T,>(obj: FinancialMetricsData, path: string): T | undefined => {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj as unknown) as T | undefined;
};
export interface KeyMetricsOverviewProps {
financialMetrics: FinancialMetricsData[];
}
export const KeyMetricsOverview: React.FC<KeyMetricsOverviewProps> = memo(({
financialMetrics,
}) => {
if (!financialMetrics || financialMetrics.length === 0) {
return null;
}
const currentPeriod = financialMetrics[0];
const previousPeriod = financialMetrics[1];
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4}>
<SimpleGrid columns={{ base: 2, md: 3, lg: 6 }} spacing={3}>
{KEY_METRICS.map((metric, idx) => {
const currentValue = getValueByPath<number>(currentPeriod, metric.path);
const previousValue = previousPeriod
? getValueByPath<number>(previousPeriod, metric.path)
: undefined;
// 计算变化
let change: number | null = null;
let trend: 'up' | 'down' | 'flat' = 'flat';
if (currentValue !== undefined && previousValue !== undefined && previousValue !== 0) {
change = currentValue - previousValue;
if (Math.abs(change) > 0.01) {
trend = change > 0 ? 'up' : 'down';
}
}
// 判断趋势是好是坏
const isPositiveTrend = metric.higherBetter ? trend === 'up' : trend === 'down';
const trendColor = trend === 'flat'
? 'gray.500'
: isPositiveTrend
? THEME.positiveColor
: THEME.negativeColor;
return (
<Box
key={idx}
p={3}
borderRadius="md"
bg={THEME.itemBg}
border="1px solid"
borderColor={THEME.itemBorder}
>
<Text fontSize="xs" color={THEME.labelColor} mb={1}>
{metric.label}
</Text>
<HStack justify="space-between" align="flex-end">
<Text fontSize="lg" fontWeight="bold" color={THEME.valueColor}>
{metric.format === 'percent'
? formatUtils.formatPercent(currentValue)
: currentValue?.toFixed(2) ?? '-'}
</Text>
{trend !== 'flat' && (
<Icon
as={trend === 'up' ? TrendingUp : TrendingDown}
boxSize={4}
color={trendColor}
/>
)}
{trend === 'flat' && (
<Icon as={Minus} boxSize={4} color="gray.500" />
)}
</HStack>
</Box>
);
})}
</SimpleGrid>
</Box>
</Box>
);
});
KeyMetricsOverview.displayName = 'KeyMetricsOverview';
export default KeyMetricsOverview;

View File

@@ -1,15 +1,13 @@
/**
* 主营业务分析组件
* 主营业务分析组件 - 黑金主题
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
VStack,
Grid,
GridItem,
Card,
CardBody,
CardHeader,
Box,
Heading,
Table,
Thead,
@@ -21,6 +19,8 @@ import {
Alert,
AlertIcon,
} from '@chakra-ui/react';
import { Table as AntTable, ConfigProvider, theme as antTheme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
import { getMainBusinessPieOption } from '../utils';
@@ -31,6 +31,142 @@ import type {
IndustryClassification,
} from '../types';
// 黑金主题样式
const THEME = {
cardBg: 'transparent',
border: 'rgba(212, 175, 55, 0.2)',
headingColor: '#D4AF37',
textColor: 'gray.300',
thColor: 'gray.400',
};
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
algorithm: antTheme.darkAlgorithm,
token: {
colorPrimary: '#D4AF37',
colorBgContainer: 'transparent',
colorBgElevated: '#1a1a2e',
colorBorder: 'rgba(212, 175, 55, 0.3)',
colorText: '#e0e0e0',
colorTextSecondary: '#a0a0a0',
borderRadius: 4,
fontSize: 13,
},
components: {
Table: {
headerBg: 'rgba(212, 175, 55, 0.1)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.05)',
borderColor: 'rgba(212, 175, 55, 0.2)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 历史对比表格数据行类型
interface HistoricalRowData {
key: string;
business: string;
[period: string]: string | number | undefined;
}
// 历史对比表格组件
interface HistoricalComparisonTableProps {
historicalData: (ProductClassification | IndustryClassification)[];
businessItems: BusinessItem[];
hasProductData: boolean;
}
const HistoricalComparisonTable: React.FC<HistoricalComparisonTableProps> = ({
historicalData,
businessItems,
hasProductData,
}) => {
// 动态生成列配置
const columns: ColumnsType<HistoricalRowData> = useMemo(() => {
const cols: ColumnsType<HistoricalRowData> = [
{
title: '业务/期间',
dataIndex: 'business',
key: 'business',
fixed: 'left',
width: 150,
},
];
// 添加各期间列
historicalData.slice(0, 4).forEach((period) => {
cols.push({
title: period.report_type,
dataIndex: period.period,
key: period.period,
align: 'right',
width: 120,
render: (value: number | string | undefined) =>
value !== undefined && value !== '-'
? formatUtils.formatLargeNumber(value as number)
: '-',
});
});
return cols;
}, [historicalData]);
// 生成表格数据
const dataSource: HistoricalRowData[] = useMemo(() => {
return businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => {
const row: HistoricalRowData = {
key: `${idx}`,
business: item.content,
};
historicalData.slice(0, 4).forEach((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
: (period as IndustryClassification).industries;
const matchItem = periodItems.find(
(p: BusinessItem) => p.content === item.content
);
row[period.period] = matchItem?.revenue ?? '-';
});
return row;
});
}, [businessItems, historicalData, hasProductData]);
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
</Heading>
</Box>
<Box p={4}>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<AntTable<HistoricalRowData>
columns={columns}
dataSource={dataSource}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
bordered
/>
</ConfigProvider>
</Box>
</Box>
);
};
export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
mainBusiness,
}) => {
@@ -42,8 +178,8 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
if (!hasProductData && !hasIndustryData) {
return (
<Alert status="info">
<AlertIcon />
<Alert status="info" bg="rgba(212, 175, 55, 0.1)" color={THEME.headingColor}>
<AlertIcon color={THEME.headingColor} />
</Alert>
);
@@ -85,26 +221,38 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
<VStack spacing={4} align="stretch">
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<GridItem>
<Card>
<CardBody>
<ReactECharts option={pieOption} style={{ height: '300px' }} />
</CardBody>
</Card>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
p={4}
>
<ReactECharts option={pieOption} style={{ height: '300px' }} />
</Box>
</GridItem>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm"> - {latestPeriod.report_type}</Heading>
</CardHeader>
<CardBody>
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.border}
borderRadius="md"
overflow="hidden"
>
<Box px={4} py={3} borderBottom="1px solid" borderColor={THEME.border}>
<Heading size="sm" color={THEME.headingColor}>
- {latestPeriod.report_type}
</Heading>
</Box>
<Box p={4}>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th isNumeric></Th>
<Th isNumeric>(%)</Th>
<Th isNumeric></Th>
<Th color={THEME.thColor} borderColor={THEME.border}></Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}></Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}>(%)</Th>
<Th isNumeric color={THEME.thColor} borderColor={THEME.border}></Th>
</Tr>
</Thead>
<Tbody>
@@ -112,69 +260,33 @@ export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => (
<Tr key={idx}>
<Td>{item.content}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.revenue)}</Td>
<Td isNumeric>
<Td color={THEME.textColor} borderColor={THEME.border}>{item.content}</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatLargeNumber(item.revenue)}
</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}
</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.profit)}</Td>
<Td isNumeric color={THEME.textColor} borderColor={THEME.border}>
{formatUtils.formatLargeNumber(item.profit)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
</Box>
</Box>
</GridItem>
</Grid>
{/* 历史对比 */}
{/* 历史对比 - Ant Design Table 黑金主题 */}
{historicalData.length > 1 && (
<Card>
<CardHeader>
<Heading size="sm"></Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>/</Th>
{historicalData.slice(0, 3).map((period) => (
<Th key={period.period} isNumeric>
{period.report_type}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{businessItems
.filter((item: BusinessItem) => item.content !== '合计')
.map((item: BusinessItem, idx: number) => (
<Tr key={idx}>
<Td>{item.content}</Td>
{historicalData.slice(0, 3).map((period) => {
const periodItems: BusinessItem[] = hasProductData
? (period as ProductClassification).products
: (period as IndustryClassification).industries;
const matchItem = periodItems.find(
(p: BusinessItem) => p.content === item.content
);
return (
<Td key={period.period} isNumeric>
{matchItem
? formatUtils.formatLargeNumber(matchItem.revenue)
: '-'}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
<HistoricalComparisonTable
historicalData={historicalData}
businessItems={businessItems}
hasProductData={hasProductData}
/>
)}
</VStack>
);

View File

@@ -1,18 +1,12 @@
/**
* 期数选择器组件
* 期数选择器组件 - 黑金主题
* 用于选择显示的财务报表期数,并提供刷新功能
*/
import React, { memo } from 'react';
import {
Card,
CardBody,
HStack,
Text,
Select,
IconButton,
} from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import { HStack, Text, IconButton } from '@chakra-ui/react';
import { Select } from 'antd';
import { RefreshCw } from 'lucide-react';
export interface PeriodSelectorProps {
/** 当前选中的期数 */
@@ -38,37 +32,62 @@ const PeriodSelector: React.FC<PeriodSelectorProps> = memo(({
label = '显示期数:',
}) => {
return (
<Card>
<CardBody>
<HStack justify="space-between">
<HStack>
<Text fontSize="sm" color="gray.600">
{label}
</Text>
<Select
value={selectedPeriods}
onChange={(e) => onPeriodsChange(Number(e.target.value))}
w="150px"
size="sm"
>
{periodOptions.map((period) => (
<option key={period} value={period}>
{period}
</option>
))}
</Select>
</HStack>
<IconButton
icon={<RepeatIcon />}
onClick={onRefresh}
isLoading={isLoading}
variant="outline"
size="sm"
aria-label="刷新数据"
/>
</HStack>
</CardBody>
</Card>
<HStack spacing={2} align="center" flexWrap="wrap">
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
{label}
</Text>
<Select
value={selectedPeriods}
onChange={(value) => onPeriodsChange(value)}
style={{
minWidth: 110,
background: 'transparent',
}}
size="small"
popupClassName="period-selector-dropdown"
options={periodOptions.map((period) => ({
value: period,
label: `最近${period}`,
}))}
dropdownStyle={{
background: '#1A202C',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
/>
<IconButton
icon={<RefreshCw size={14} className={isLoading ? 'spin' : ''} />}
onClick={onRefresh}
isLoading={isLoading}
variant="outline"
size="sm"
aria-label="刷新数据"
borderColor="rgba(212, 175, 55, 0.3)"
color="#D4AF37"
_hover={{
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.5)',
}}
/>
<style>{`
.period-selector-dropdown .ant-select-item {
color: #E2E8F0;
}
.period-selector-dropdown .ant-select-item-option-selected {
background: rgba(212, 175, 55, 0.2) !important;
color: #D4AF37;
}
.period-selector-dropdown .ant-select-item-option-active {
background: rgba(212, 175, 55, 0.1) !important;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</HStack>
);
});

View File

@@ -3,6 +3,7 @@
*/
export { PeriodSelector } from './PeriodSelector';
export { KeyMetricsOverview } from './KeyMetricsOverview';
export { StockInfoHeader } from './StockInfoHeader';
export { BalanceSheetTable } from './BalanceSheetTable';
export { IncomeStatementTable } from './IncomeStatementTable';

View File

@@ -3,4 +3,5 @@
*/
export { useFinancialData } from './useFinancialData';
export type { DataTypeKey } from './useFinancialData';
export type { default as UseFinancialDataReturn } from './useFinancialData';

View File

@@ -1,9 +1,9 @@
/**
* 财务数据加载 Hook
* 封装所有财务数据的加载逻辑
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@chakra-ui/react';
import { logger } from '@utils/logger';
import { financialService } from '@services/financialService';
@@ -19,6 +19,19 @@ import type {
ComparisonData,
} from '../types';
// Tab key 到数据类型的映射
export type DataTypeKey =
| 'balance'
| 'income'
| 'cashflow'
| 'profitability'
| 'perShare'
| 'growth'
| 'operational'
| 'solvency'
| 'expense'
| 'cashflowMetrics';
interface UseFinancialDataOptions {
stockCode?: string;
periods?: number;
@@ -38,16 +51,20 @@ interface UseFinancialDataReturn {
// 加载状态
loading: boolean;
loadingTab: DataTypeKey | null; // 当前正在加载的 Tab
error: string | null;
// 操作方法
refetch: () => Promise<void>;
refetchByTab: (tabKey: DataTypeKey) => Promise<void>;
setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void;
// 当前参数
currentStockCode: string;
selectedPeriods: number;
activeTab: DataTypeKey;
}
/**
@@ -62,10 +79,12 @@ export const useFinancialData = (
// 参数状态
const [stockCode, setStockCode] = useState(initialStockCode);
const [selectedPeriods, setSelectedPeriods] = useState(initialPeriods);
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
// 加载状态
const [loading, setLoading] = useState(false);
const [loadingTab, setLoadingTab] = useState<DataTypeKey | null>(null);
const [error, setError] = useState<string | null>(null);
// 财务数据状态
@@ -80,9 +99,88 @@ export const useFinancialData = (
const [comparison, setComparison] = useState<ComparisonData[]>([]);
const toast = useToast();
const isInitialLoad = useRef(true);
const prevPeriods = useRef(selectedPeriods);
// 加载所有财务数据
const loadFinancialData = useCallback(async () => {
// 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) {
case 'balance':
return 'balance';
case 'income':
return 'income';
case 'cashflow':
return 'cashflow';
default:
// 所有财务指标类 tab 都使用 metrics 数据
return 'metrics';
}
};
// 按数据类型加载数据
const loadDataByType = useCallback(async (
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
periods: number
) => {
try {
switch (dataType) {
case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods);
if (res.success) setBalanceSheet(res.data);
break;
}
case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods);
if (res.success) setIncomeStatement(res.data);
break;
}
case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods);
if (res.success) setCashflow(res.data);
break;
}
case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods);
if (res.success) setFinancialMetrics(res.data);
break;
}
}
} catch (err) {
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
throw err;
}
}, [stockCode]);
// 按 Tab 刷新数据
const refetchByTab = useCallback(async (tabKey: DataTypeKey) => {
if (!stockCode || stockCode.length !== 6) {
return;
}
const dataType = getDataTypeForTab(tabKey);
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
setLoadingTab(tabKey);
setError(null);
try {
await loadDataByType(dataType, selectedPeriods);
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
} finally {
setLoadingTab(null);
}
}, [stockCode, selectedPeriods, loadDataByType]);
// 设置期数(只刷新当前 Tab
const setSelectedPeriods = useCallback((periods: number) => {
setSelectedPeriodsState(periods);
}, []);
// 加载所有财务数据(初始加载)
const loadAllFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) {
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
toast({
@@ -93,7 +191,7 @@ export const useFinancialData = (
return;
}
logger.debug('useFinancialData', '开始加载财务数据', { stockCode, selectedPeriods });
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods });
setLoading(true);
setError(null);
@@ -132,11 +230,11 @@ export const useFinancialData = (
if (rankRes.success) setIndustryRank(rankRes.data);
if (comparisonRes.success) setComparison(comparisonRes.data);
logger.info('useFinancialData', '财务数据加载成功', { stockCode });
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
logger.error('useFinancialData', 'loadFinancialData', err, { stockCode, selectedPeriods });
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
} finally {
setLoading(false);
}
@@ -149,12 +247,21 @@ export const useFinancialData = (
}
}, [initialStockCode]);
// 初始加载和参数变化时重新加载
// 初始加载(仅股票代码变化时全量加载
useEffect(() => {
if (stockCode) {
loadFinancialData();
loadAllFinancialData();
isInitialLoad.current = false;
}
}, [stockCode, selectedPeriods, loadFinancialData]);
}, [stockCode]); // 注意:这里只依赖 stockCode
// 期数变化时只刷新当前 Tab
useEffect(() => {
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
prevPeriods.current = selectedPeriods;
refetchByTab(activeTab);
}
}, [selectedPeriods, activeTab, refetchByTab]);
return {
// 数据状态
@@ -170,16 +277,20 @@ export const useFinancialData = (
// 加载状态
loading,
loadingTab,
error,
// 操作方法
refetch: loadFinancialData,
refetch: loadAllFinancialData,
refetchByTab,
setStockCode,
setSelectedPeriods,
setActiveTab,
// 当前参数
currentStockCode: stockCode,
selectedPeriods,
activeTab,
};
};

View File

@@ -3,17 +3,14 @@
* 重构后的主组件,使用模块化结构和 SubTabContainer 二级导航
*/
import React, { useState, useMemo, ReactNode } from 'react';
import React, { useState, useMemo, useCallback, ReactNode } from 'react';
import {
Box,
Container,
VStack,
HStack,
Card,
CardBody,
Text,
Select,
IconButton,
Alert,
AlertIcon,
Skeleton,
@@ -33,8 +30,18 @@ import {
TableContainer,
Divider,
} from '@chakra-ui/react';
import { RepeatIcon } from '@chakra-ui/icons';
import { BarChart3, DollarSign, TrendingUp } from 'lucide-react';
import {
BarChart3,
DollarSign,
TrendingUp,
PieChart,
Percent,
TrendingDown,
Activity,
Shield,
Receipt,
Banknote,
} from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { formatUtils } from '@services/financialService';
@@ -42,20 +49,41 @@ import { formatUtils } from '@services/financialService';
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
// 内部模块导入
import { useFinancialData } from './hooks';
import { useFinancialData, type DataTypeKey } from './hooks';
import { COLORS } from './constants';
import { calculateYoYChange, getCellBackground, getMetricChartOption } from './utils';
import { PeriodSelector, KeyMetricsOverview, StockInfoHeader, MainBusinessAnalysis } from './components';
import {
StockInfoHeader,
FinancialMetricsTable,
MainBusinessAnalysis,
} from './components';
import { BalanceSheetTab, IncomeStatementTab, CashflowTab } from './tabs';
BalanceSheetTab,
IncomeStatementTab,
CashflowTab,
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './tabs';
import type { FinancialPanoramaProps } from './types';
/**
* 财务全景主组件
*/
// Tab key 映射表SubTabContainer index -> DataTypeKey
const TAB_KEY_MAP: DataTypeKey[] = [
'profitability',
'perShare',
'growth',
'operational',
'solvency',
'expense',
'cashflowMetrics',
'balance',
'income',
'cashflow',
];
const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propStockCode }) => {
// 使用数据加载 Hook
const {
@@ -66,12 +94,26 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
financialMetrics,
mainBusiness,
loading,
loadingTab,
error,
refetch,
refetchByTab,
selectedPeriods,
setSelectedPeriods,
setActiveTab,
activeTab,
} = useFinancialData({ stockCode: propStockCode });
// 处理 Tab 切换
const handleTabChange = useCallback((index: number, tabKey: string) => {
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
setActiveTab(dataTypeKey);
}, [setActiveTab]);
// 处理刷新 - 只刷新当前 Tab
const handleRefresh = useCallback(() => {
refetchByTab(activeTab);
}, [refetchByTab, activeTab]);
// UI 状态
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState<ReactNode>(null);
@@ -180,23 +222,21 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
onOpen();
};
// 通用表格属性
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
// Tab 配置 - 只保留三大财务报表
// Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo(
() => [
// 财务指标分类7个
{ key: 'profitability', name: '盈利能力', icon: PieChart, component: ProfitabilityTab },
{ key: 'perShare', name: '每股指标', icon: Percent, component: PerShareTab },
{ key: 'growth', name: '成长能力', icon: TrendingUp, component: GrowthTab },
{ key: 'operational', name: '运营效率', icon: Activity, component: OperationalTab },
{ key: 'solvency', name: '偿债能力', icon: Shield, component: SolvencyTab },
{ key: 'expense', name: '费用率', icon: Receipt, component: ExpenseTab },
{ key: 'cashflowMetrics', name: '现金流指标', icon: Banknote, component: CashflowMetricsTab },
// 三大财务报表
{ key: 'balance', name: '资产负债表', icon: BarChart3, component: BalanceSheetTab },
{ key: 'income', name: '利润表', icon: DollarSign, component: IncomeStatementTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingUp, component: CashflowTab },
{ key: 'cashflow', name: '现金流量表', icon: TrendingDown, component: CashflowTab },
],
[]
);
@@ -208,6 +248,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
// 工具函数
showMetricChart,
calculateYoYChange,
@@ -222,6 +263,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
showMetricChart,
positiveColor,
negativeColor,
@@ -233,38 +275,6 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 时间选择器 */}
<Card>
<CardBody>
<HStack justify="space-between">
<HStack>
<Text fontSize="sm" color="gray.600">
</Text>
<Select
value={selectedPeriods}
onChange={(e) => setSelectedPeriods(Number(e.target.value))}
w="150px"
size="sm"
>
<option value={4}>4</option>
<option value={8}>8</option>
<option value={12}>12</option>
<option value={16}>16</option>
</Select>
</HStack>
<IconButton
icon={<RepeatIcon />}
onClick={refetch}
isLoading={loading}
variant="outline"
size="sm"
aria-label="刷新数据"
/>
</HStack>
</CardBody>
</Card>
{/* 股票信息头部 */}
{loading ? (
<Skeleton height="150px" />
@@ -276,16 +286,16 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
/>
)}
{/* 财务指标速览 */}
{!loading && stockInfo && (
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
{/* 关键指标速览 */}
{!loading && stockInfo && financialMetrics.length > 0 && (
<KeyMetricsOverview financialMetrics={financialMetrics} />
)}
{/* 主营业务 */}
{!loading && stockInfo && (
<Card>
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody>
<Text fontSize="lg" fontWeight="bold" mb={4}>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
@@ -302,6 +312,15 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
componentProps={componentProps}
themePreset="blackGold"
isLazy
onTabChange={handleTabChange}
rightElement={
<PeriodSelector
selectedPeriods={selectedPeriods}
onPeriodsChange={setSelectedPeriods}
onRefresh={handleRefresh}
isLoading={loadingTab !== null}
/>
}
/>
</CardBody>
</Card>

View File

@@ -3,16 +3,7 @@
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Text,
} from '@chakra-ui/react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types';
@@ -48,29 +39,25 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
};
return (
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(balanceSheet.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
</CardHeader>
<CardBody>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</CardBody>
</Card>
</HStack>
<Text fontSize="xs" color="gray.500">
</Text>
</VStack>
<BalanceSheetTable data={balanceSheet} {...tableProps} />
</Box>
);
};

View File

@@ -3,16 +3,7 @@
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Text,
} from '@chakra-ui/react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { CashflowTable } from '../components';
import type { CashflowData } from '../types';
@@ -48,29 +39,25 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
};
return (
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(cashflow.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
</CardHeader>
<CardBody>
<CashflowTable data={cashflow} {...tableProps} />
</CardBody>
</Card>
</HStack>
<Text fontSize="xs" color="gray.500">
绿
</Text>
</VStack>
<CashflowTable data={cashflow} {...tableProps} />
</Box>
);
};

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types';
export interface MetricsTabProps {
export interface FinancialMetricsTabProps {
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
@@ -17,7 +17,7 @@ export interface MetricsTabProps {
hoverBg: string;
}
const MetricsTab: React.FC<MetricsTabProps> = ({
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
financialMetrics,
showMetricChart,
calculateYoYChange,
@@ -37,7 +37,9 @@ const MetricsTab: React.FC<MetricsTabProps> = ({
hoverBg,
};
return <FinancialMetricsTable data={financialMetrics} {...tableProps} />;
return (
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
);
};
export default MetricsTab;
export default FinancialMetricsTab;

View File

@@ -3,16 +3,7 @@
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Text,
} from '@chakra-ui/react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types';
@@ -48,29 +39,25 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
};
return (
<Card>
<CardHeader>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Heading size="md"></Heading>
<HStack spacing={2}>
<Badge colorScheme="blue">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.500">
绿 |
</Text>
</HStack>
<Box>
<VStack align="stretch" spacing={2} mb={4}>
<HStack justify="space-between">
<Heading size="md" color="#D4AF37"></Heading>
<HStack spacing={2}>
<Badge bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
{Math.min(incomeStatement.length, 8)}
</Badge>
<Text fontSize="sm" color="gray.400">
绿 |
</Text>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
</CardHeader>
<CardBody>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</CardBody>
</Card>
</HStack>
<Text fontSize="xs" color="gray.500">
Q1Q3
</Text>
</VStack>
<IncomeStatementTable data={incomeStatement} {...tableProps} />
</Box>
);
};

View File

@@ -1,17 +0,0 @@
/**
* 主营业务 Tab
*/
import React from 'react';
import { MainBusinessAnalysis } from '../components';
import type { MainBusinessData } from '../types';
export interface MainBusinessTabProps {
mainBusiness: MainBusinessData | null;
}
const MainBusinessTab: React.FC<MainBusinessTabProps> = ({ mainBusiness }) => {
return <MainBusinessAnalysis mainBusiness={mainBusiness} />;
};
export default MainBusinessTab;

View File

@@ -0,0 +1,330 @@
/**
* 财务指标分类 Tab - Ant Design 黑金主题
* 接受 categoryKey 显示单个分类的指标表格
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.metrics-category-table .ant-table {
background: transparent !important;
}
.metrics-category-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.metrics-category-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.metrics-category-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.metrics-category-table .ant-table-cell-fix-left,
.metrics-category-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.metrics-category-table .positive-change {
color: #E53E3E;
}
.metrics-category-table .negative-change {
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 {
categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[];
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 MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
categoryKey,
financialMetrics,
showMetricChart,
calculateYoYChange,
}) => {
// 数组安全检查
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 calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = financialMetrics.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
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 = 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 = 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_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>
);
};
// 为每个分类创建预配置的组件
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} />
);
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} />
);
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="growth" {...props} />
);
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="operational" {...props} />
);
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} />
);
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="expense" {...props} />
);
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} />
);
export default MetricsCategoryTab;

View File

@@ -1,51 +0,0 @@
/**
* 财务概览 Tab
*/
import React from 'react';
import { VStack } from '@chakra-ui/react';
import { ComparisonAnalysis, FinancialMetricsTable } from '../components';
import type { FinancialMetricsData, ComparisonData } from '../types';
export interface OverviewTabProps {
comparison: ComparisonData[];
financialMetrics: FinancialMetricsData[];
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 OverviewTab: React.FC<OverviewTabProps> = ({
comparison,
financialMetrics,
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
}) => {
const tableProps = {
showMetricChart,
calculateYoYChange,
getCellBackground,
positiveColor,
negativeColor,
bgColor,
hoverBg,
};
return (
<VStack spacing={4} align="stretch">
<ComparisonAnalysis comparison={comparison} />
<FinancialMetricsTable data={financialMetrics} {...tableProps} />
</VStack>
);
};
export default OverviewTab;

View File

@@ -1,12 +1,28 @@
/**
* Tab 组件统一导出
* 仅保留三大财务报表 Tab
*/
// 三大财务报表
export { default as BalanceSheetTab } from './BalanceSheetTab';
export { default as IncomeStatementTab } from './IncomeStatementTab';
export { default as CashflowTab } from './CashflowTab';
// 财务指标分类 tabs
export {
ProfitabilityTab,
PerShareTab,
GrowthTab,
OperationalTab,
SolvencyTab,
ExpenseTab,
CashflowMetricsTab,
} from './MetricsCategoryTab';
// 旧的综合财务指标 tab保留兼容
export { default as FinancialMetricsTab } from './FinancialMetricsTab';
export type { BalanceSheetTabProps } from './BalanceSheetTab';
export type { IncomeStatementTabProps } from './IncomeStatementTab';
export type { CashflowTabProps } from './CashflowTab';
export type { FinancialMetricsTabProps } from './FinancialMetricsTab';
export type { MetricsCategoryTabProps } from './MetricsCategoryTab';