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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { KeyMetricsOverview } from './KeyMetricsOverview';
|
||||
export { StockInfoHeader } from './StockInfoHeader';
|
||||
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||
export { IncomeStatementTable } from './IncomeStatementTable';
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
*/
|
||||
|
||||
export { useFinancialData } from './useFinancialData';
|
||||
export type { DataTypeKey } from './useFinancialData';
|
||||
export type { default as UseFinancialDataReturn } from './useFinancialData';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
提示:Q1、中报、Q3、年报数据为累计值,同比显示与去年同期对比
|
||||
</Text>
|
||||
</VStack>
|
||||
<IncomeStatementTable data={incomeStatement} {...tableProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user