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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,330 @@
/**
* 财务指标分类 Tab - Ant Design 黑金主题
* 接受 categoryKey 显示单个分类的指标表格
*/
import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react';
import { formatUtils } from '@services/financialService';
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
import { getValueByPath, isNegativeIndicator } from '../utils';
import type { FinancialMetricsData } from '../types';
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
// Ant Design 黑金主题配置
const BLACK_GOLD_THEME = {
token: {
colorBgContainer: 'transparent',
colorText: '#E2E8F0',
colorTextHeading: '#D4AF37',
colorBorderSecondary: 'rgba(212, 175, 55, 0.2)',
},
components: {
Table: {
headerBg: 'rgba(26, 32, 44, 0.8)',
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
},
};
// 黑金主题CSS
const tableStyles = `
.metrics-category-table .ant-table {
background: transparent !important;
}
.metrics-category-table .ant-table-thead > tr > th {
background: rgba(26, 32, 44, 0.8) !important;
color: #D4AF37 !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600;
font-size: 13px;
}
.metrics-category-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
color: #E2E8F0;
font-size: 12px;
}
.metrics-category-table .ant-table-tbody > tr:hover > td {
background: rgba(212, 175, 55, 0.08) !important;
}
.metrics-category-table .ant-table-cell-fix-left,
.metrics-category-table .ant-table-cell-fix-right {
background: #1A202C !important;
}
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-left,
.metrics-category-table .ant-table-tbody > tr:hover .ant-table-cell-fix-right {
background: rgba(26, 32, 44, 0.95) !important;
}
.metrics-category-table .positive-change {
color: #E53E3E;
}
.metrics-category-table .negative-change {
color: #48BB78;
}
.metrics-category-table .positive-value {
color: #E53E3E;
}
.metrics-category-table .negative-value {
color: #48BB78;
}
.metrics-category-table .ant-table-placeholder {
background: transparent !important;
}
.metrics-category-table .ant-empty-description {
color: #A0AEC0;
}
`;
export interface MetricsCategoryTabProps {
categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[];
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string;
positiveColor: string;
negativeColor: string;
bgColor: string;
hoverBg: string;
}
// 表格行数据类型
interface TableRowData {
key: string;
name: string;
path: string;
isCore?: boolean;
[period: string]: unknown;
}
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
categoryKey,
financialMetrics,
showMetricChart,
calculateYoYChange,
}) => {
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const category = FINANCIAL_METRICS_CATEGORIES[categoryKey];
if (!category) {
return (
<Box p={4} textAlign="center" color="gray.400">
</Box>
);
}
// 构建表格数据
const tableData = useMemo(() => {
return category.metrics.map((metric) => {
const row: TableRowData = {
key: metric.key,
name: metric.name,
path: metric.path,
isCore: metric.isCore,
};
// 添加各期数值
displayData.forEach((item) => {
const value = getValueByPath<number>(item, metric.path);
row[item.period] = value;
});
return row;
});
}, [financialMetrics, displayData, category]);
// 计算同比变化
const calculateYoY = (
currentValue: number | undefined,
currentPeriod: string,
path: string
): number | null => {
if (currentValue === undefined || currentValue === null) return null;
const currentDate = new Date(currentPeriod);
const lastYearPeriod = financialMetrics.find((item) => {
const date = new Date(item.period);
return (
date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth()
);
});
if (!lastYearPeriod) return null;
const lastYearValue = getValueByPath<number>(lastYearPeriod, path);
if (lastYearValue === undefined || lastYearValue === 0) return null;
return ((currentValue - lastYearValue) / Math.abs(lastYearValue)) * 100;
};
// 构建列定义
const columns: ColumnsType<TableRowData> = useMemo(() => {
const cols: ColumnsType<TableRowData> = [
{
title: category.title,
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (name: string, record: TableRowData) => (
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">{name}</Text>
{record.isCore && (
<ChakraBadge size="sm" bg="rgba(212, 175, 55, 0.2)" color="#D4AF37">
</ChakraBadge>
)}
</HStack>
),
},
...displayData.map((item) => ({
title: (
<Box textAlign="center">
<Text fontSize="xs">{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.400">{item.period.substring(0, 10)}</Text>
</Box>
),
dataIndex: item.period,
key: item.period,
width: 100,
align: 'right' as const,
render: (value: number | undefined, record: TableRowData) => {
const yoy = calculateYoY(value, item.period, record.path);
const isNegative = isNegativeIndicator(record.key);
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const changeColor = isNegative
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
// 成长能力指标特殊处理:正值红色,负值绿色
const valueColor = categoryKey === 'growth'
? (value !== undefined && value > 0 ? 'positive-value' : value !== undefined && value < 0 ? 'negative-value' : '')
: '';
return (
<Tooltip
title={
<Box>
<Text>{record.name}: {value?.toFixed(2) || '-'}</Text>
{yoy !== null && <Text>: {yoy.toFixed(2)}%</Text>}
</Box>
}
>
<Box position="relative">
<Text fontSize="xs" className={valueColor || undefined}>
{value?.toFixed(2) || '-'}
</Text>
{yoy !== null && Math.abs(yoy) > 20 && value !== undefined && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-12px"
right="0"
fontSize="10px"
className={changeColor}
>
{yoy > 0 ? '↑' : '↓'}
</Text>
)}
</Box>
</Tooltip>
);
},
})),
{
title: '',
key: 'action',
width: 40,
fixed: 'right',
render: (_: unknown, record: TableRowData) => (
<Eye
size={14}
color="#D4AF37"
style={{ cursor: 'pointer', opacity: 0.7 }}
onClick={(e) => {
e.stopPropagation();
showMetricChart(record.name, record.key, financialMetrics, record.path);
}}
/>
),
},
];
return cols;
}, [displayData, financialMetrics, showMetricChart, category, categoryKey]);
return (
<Box>
<Box className="metrics-category-table">
<style>{tableStyles}</style>
<ConfigProvider theme={BLACK_GOLD_THEME}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
showMetricChart(record.name, record.key, financialMetrics, record.path);
},
style: { cursor: 'pointer' },
})}
locale={{ emptyText: '暂无数据' }}
/>
</ConfigProvider>
</Box>
</Box>
);
};
// 为每个分类创建预配置的组件
export const ProfitabilityTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="profitability" {...props} />
);
export const PerShareTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="perShare" {...props} />
);
export const GrowthTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="growth" {...props} />
);
export const OperationalTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="operational" {...props} />
);
export const SolvencyTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="solvency" {...props} />
);
export const ExpenseTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="expense" {...props} />
);
export const CashflowMetricsTab: React.FC<Omit<MetricsCategoryTabProps, 'categoryKey'>> = (props) => (
<MetricsCategoryTab categoryKey="cashflow" {...props} />
);
export default MetricsCategoryTab;

View File

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

View File

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