feat(FinancialPanorama): 添加页面骨架屏
- 创建 FinancialPanoramaSkeleton 组件 - 初始加载时显示完整骨架屏(概览面板、图表、主营业务、Tab) - 优化加载体验,避免内容闪烁 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 财务全景骨架屏组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonText,
|
||||||
|
SimpleGrid,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// 黑金主题配色
|
||||||
|
const SKELETON_COLORS = {
|
||||||
|
startColor: 'rgba(26, 32, 44, 0.6)',
|
||||||
|
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务概览面板骨架屏
|
||||||
|
*/
|
||||||
|
const OverviewPanelSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={6}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Skeleton
|
||||||
|
height="20px"
|
||||||
|
width="100px"
|
||||||
|
mb={4}
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<HStack key={j} justify="space-between">
|
||||||
|
<Skeleton height="16px" width="80px" {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="16px" width="60px" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
OverviewPanelSkeleton.displayName = 'OverviewPanelSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表区域骨架屏
|
||||||
|
*/
|
||||||
|
const ChartSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="20px" width="120px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="200px" borderRadius="md" {...SKELETON_COLORS} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
ChartSkeleton.displayName = 'ChartSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主营业务骨架屏
|
||||||
|
*/
|
||||||
|
const MainBusinessSkeleton: React.FC = memo(() => (
|
||||||
|
<Box>
|
||||||
|
<Skeleton height="24px" width="100px" mb={4} {...SKELETON_COLORS} />
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Card key={i} bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton height="18px" width="80px" mb={3} {...SKELETON_COLORS} />
|
||||||
|
<VStack align="stretch" spacing={2}>
|
||||||
|
{[1, 2, 3].map((j) => (
|
||||||
|
<HStack key={j} justify="space-between">
|
||||||
|
<Skeleton height="14px" width="100px" {...SKELETON_COLORS} />
|
||||||
|
<Skeleton height="14px" width="50px" {...SKELETON_COLORS} />
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
MainBusinessSkeleton.displayName = 'MainBusinessSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab 区域骨架屏
|
||||||
|
*/
|
||||||
|
const TabSkeleton: React.FC = memo(() => (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody p={0}>
|
||||||
|
{/* Tab 栏 */}
|
||||||
|
<HStack
|
||||||
|
spacing={2}
|
||||||
|
p={3}
|
||||||
|
borderBottom="1px solid"
|
||||||
|
borderColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
overflowX="auto"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
height="32px"
|
||||||
|
width="80px"
|
||||||
|
borderRadius="md"
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<Box p={4}>
|
||||||
|
<SkeletonText
|
||||||
|
noOfLines={8}
|
||||||
|
spacing={4}
|
||||||
|
skeletonHeight={4}
|
||||||
|
{...SKELETON_COLORS}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
TabSkeleton.displayName = 'TabSkeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务全景完整骨架屏
|
||||||
|
*/
|
||||||
|
const FinancialPanoramaSkeleton: React.FC = memo(() => (
|
||||||
|
<VStack spacing={6} align="stretch">
|
||||||
|
<OverviewPanelSkeleton />
|
||||||
|
<ChartSkeleton />
|
||||||
|
<MainBusinessSkeleton />
|
||||||
|
<TabSkeleton />
|
||||||
|
</VStack>
|
||||||
|
));
|
||||||
|
|
||||||
|
FinancialPanoramaSkeleton.displayName = 'FinancialPanoramaSkeleton';
|
||||||
|
|
||||||
|
export { FinancialPanoramaSkeleton };
|
||||||
|
export default FinancialPanoramaSkeleton;
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* 统一财务表格组件 - Ant Design 黑金主题
|
||||||
|
*
|
||||||
|
* 支持两种表格类型:
|
||||||
|
* - metrics: 财务指标表格(7个分类,扁平结构)
|
||||||
|
* - statement: 财务报表表格(3个报表,分组结构)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, memo } from 'react';
|
||||||
|
import { Box, Text, HStack, Badge as ChakraBadge, Button, Spinner, Center } from '@chakra-ui/react';
|
||||||
|
import { Table, ConfigProvider, Tooltip } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { BLACK_GOLD_TABLE_THEME, getTableStyles, calculateYoY, getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { MetricConfig, MetricSectionConfig } from '../types';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
|
export type TableType = 'metrics' | 'statement';
|
||||||
|
|
||||||
|
export interface UnifiedFinancialTableProps {
|
||||||
|
/** 表格类型: metrics=指标表格, statement=报表表格 */
|
||||||
|
type: TableType;
|
||||||
|
/** 数据数组 */
|
||||||
|
data: Array<{ period: string; [key: string]: unknown }>;
|
||||||
|
/** metrics 类型: 指标分类 key */
|
||||||
|
categoryKey?: string;
|
||||||
|
/** metrics 类型: 分类标题 */
|
||||||
|
categoryTitle?: string;
|
||||||
|
/** metrics 类型: 指标配置数组 */
|
||||||
|
metrics?: MetricConfig[];
|
||||||
|
/** statement 类型: 分组配置数组 */
|
||||||
|
sections?: MetricSectionConfig[];
|
||||||
|
/** 是否隐藏汇总行的分组标题 */
|
||||||
|
hideTotalSectionTitle?: boolean;
|
||||||
|
/** 点击行显示图表回调 */
|
||||||
|
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||||
|
/** 是否为成长类指标(正负值着色) */
|
||||||
|
isGrowthCategory?: boolean;
|
||||||
|
/** 核心指标 keys(用于 cashflow 表格) */
|
||||||
|
coreMetricKeys?: string[];
|
||||||
|
/** 加载中 */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行数据类型
|
||||||
|
interface TableRowData {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isCore?: boolean;
|
||||||
|
isTotal?: boolean;
|
||||||
|
isSubtotal?: boolean;
|
||||||
|
isSection?: boolean;
|
||||||
|
indent?: number;
|
||||||
|
[period: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_CLASS_NAME = 'unified-financial-table';
|
||||||
|
|
||||||
|
// 扩展样式(支持 positive-value, negative-value)
|
||||||
|
const extendedTableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
||||||
|
.${TABLE_CLASS_NAME} .positive-value {
|
||||||
|
color: #E53E3E;
|
||||||
|
}
|
||||||
|
.${TABLE_CLASS_NAME} .negative-value {
|
||||||
|
color: #48BB78;
|
||||||
|
}
|
||||||
|
.${TABLE_CLASS_NAME} .ant-table-tbody > tr.subtotal-row > td {
|
||||||
|
background: rgba(212, 175, 55, 0.1) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ==================== 组件实现 ====================
|
||||||
|
|
||||||
|
const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
categoryKey,
|
||||||
|
categoryTitle,
|
||||||
|
metrics,
|
||||||
|
sections,
|
||||||
|
hideTotalSectionTitle = true,
|
||||||
|
showMetricChart,
|
||||||
|
isGrowthCategory = false,
|
||||||
|
coreMetricKeys = [],
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
// 加载中状态
|
||||||
|
if (loading && (!Array.isArray(data) || data.length === 0)) {
|
||||||
|
return (
|
||||||
|
<Center py={12}>
|
||||||
|
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box p={4} textAlign="center" color="gray.400">
|
||||||
|
暂无数据
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制显示列数
|
||||||
|
const maxColumns = type === 'metrics' ? 6 : (type === 'statement' ? 8 : 6);
|
||||||
|
const displayData = data.slice(0, Math.min(data.length, maxColumns));
|
||||||
|
|
||||||
|
// 构建表格数据
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
const rows: TableRowData[] = [];
|
||||||
|
|
||||||
|
if (type === 'metrics' && metrics) {
|
||||||
|
// 财务指标表格: 扁平结构
|
||||||
|
metrics.forEach((metric) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore || coreMetricKeys.includes(metric.key),
|
||||||
|
};
|
||||||
|
|
||||||
|
displayData.forEach((item) => {
|
||||||
|
const value = getValueByPath<number>(item, metric.path);
|
||||||
|
row[item.period] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
} else if (type === 'statement' && sections) {
|
||||||
|
// 财务报表表格: 分组结构
|
||||||
|
sections.forEach((section) => {
|
||||||
|
// 添加分组标题行(可配置隐藏汇总行标题)
|
||||||
|
const isTotalSection = section.title.includes('总计') || section.title.includes('合计');
|
||||||
|
if (!isTotalSection || !hideTotalSectionTitle) {
|
||||||
|
rows.push({
|
||||||
|
key: `section-${section.key}`,
|
||||||
|
name: section.title,
|
||||||
|
path: '',
|
||||||
|
isSection: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加指标行
|
||||||
|
section.metrics.forEach((metric) => {
|
||||||
|
const row: TableRowData = {
|
||||||
|
key: metric.key,
|
||||||
|
name: metric.name,
|
||||||
|
path: metric.path,
|
||||||
|
isCore: metric.isCore,
|
||||||
|
isTotal: metric.isTotal || isTotalSection,
|
||||||
|
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;
|
||||||
|
}, [type, metrics, sections, displayData, data, hideTotalSectionTitle, coreMetricKeys]);
|
||||||
|
|
||||||
|
// 计算同比变化
|
||||||
|
const calcYoY = (
|
||||||
|
currentValue: number | undefined,
|
||||||
|
currentPeriod: string,
|
||||||
|
path: string
|
||||||
|
): number | null => {
|
||||||
|
return calculateYoY(data, currentValue, currentPeriod, path, getValueByPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建列定义
|
||||||
|
const columns: ColumnsType<TableRowData> = useMemo(() => {
|
||||||
|
const cols: ColumnsType<TableRowData> = [
|
||||||
|
// 指标名称列
|
||||||
|
{
|
||||||
|
title: type === 'metrics' ? (categoryTitle || '指标') : '项目',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: type === 'metrics' ? 200 : 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' : type === 'metrics' ? 'medium' : 'normal'}
|
||||||
|
fontSize={type === 'metrics' ? 'xs' : undefined}
|
||||||
|
>
|
||||||
|
{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: type === 'metrics' ? 100 : 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (value: number | undefined, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
const yoy = calcYoY(value, item.period, record.path);
|
||||||
|
const isNegative = isNegativeIndicator(record.key);
|
||||||
|
|
||||||
|
// 值格式化
|
||||||
|
let formattedValue: string;
|
||||||
|
let valueColorClass = '';
|
||||||
|
|
||||||
|
if (type === 'metrics') {
|
||||||
|
formattedValue = value?.toFixed(2) || '-';
|
||||||
|
// 成长类指标: 正值红色,负值绿色
|
||||||
|
if (isGrowthCategory) {
|
||||||
|
valueColorClass = value !== undefined && value > 0
|
||||||
|
? 'positive-value'
|
||||||
|
: value !== undefined && value < 0
|
||||||
|
? 'negative-value'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 财务报表:使用大数格式化
|
||||||
|
const isEPS = record.key.includes('eps');
|
||||||
|
formattedValue = isEPS ? (value?.toFixed(3) || '-') : formatUtils.formatLargeNumber(value, 0);
|
||||||
|
// 利润表负值着色
|
||||||
|
if (value !== undefined && value < 0) {
|
||||||
|
valueColorClass = 'negative-value';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同比变化颜色(负向指标逻辑反转)
|
||||||
|
const changeColor = isNegative
|
||||||
|
? (yoy && yoy > 0 ? 'negative-change' : 'positive-change')
|
||||||
|
: (yoy && yoy > 0 ? 'positive-change' : 'negative-change');
|
||||||
|
|
||||||
|
// 显示同比箭头的阈值
|
||||||
|
const yoyThreshold = type === 'metrics' ? 20 : 30;
|
||||||
|
const showYoyArrow = yoy !== null && Math.abs(yoy) > yoyThreshold && !record.isTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Text>{record.name}: {type === 'metrics' ? (value?.toFixed(2) || '-') : formatUtils.formatLargeNumber(value)}</Text>
|
||||||
|
{yoy !== null && <Text>同比: {yoy.toFixed(2)}%</Text>}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box position="relative">
|
||||||
|
<Text
|
||||||
|
fontSize={type === 'metrics' ? 'xs' : undefined}
|
||||||
|
fontWeight={record.isTotal || record.isSubtotal ? 'bold' : 'normal'}
|
||||||
|
className={valueColorClass || undefined}
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</Text>
|
||||||
|
{showYoyArrow && value !== undefined && Math.abs(value) > 0.01 && (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
top="-12px"
|
||||||
|
right="0"
|
||||||
|
fontSize="10px"
|
||||||
|
className={changeColor}
|
||||||
|
>
|
||||||
|
{yoy > 0 ? '↑' : '↓'}{type === 'statement' && Math.abs(yoy) < 100 ? `${Math.abs(yoy).toFixed(0)}%` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
// 操作列
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'action',
|
||||||
|
width: type === 'metrics' ? 40 : 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_: unknown, record: TableRowData) => {
|
||||||
|
if (record.isSection) return null;
|
||||||
|
|
||||||
|
if (type === 'metrics') {
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="#D4AF37"
|
||||||
|
borderColor="#D4AF37"
|
||||||
|
_hover={{ bg: 'rgba(212, 175, 55, 0.2)' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
趋势
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [type, displayData, data, showMetricChart, categoryTitle, isGrowthCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={TABLE_CLASS_NAME}>
|
||||||
|
<style>{extendedTableStyles}</style>
|
||||||
|
<ConfigProvider theme={BLACK_GOLD_TABLE_THEME}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
rowClassName={(record) => {
|
||||||
|
if (record.isSection) return 'section-header';
|
||||||
|
if (record.isTotal) return 'total-row';
|
||||||
|
if (record.isSubtotal) return 'subtotal-row';
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
if (!record.isSection) {
|
||||||
|
showMetricChart(record.name, record.key, data, record.path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: { cursor: record.isSection ? 'default' : 'pointer' },
|
||||||
|
})}
|
||||||
|
locale={{ emptyText: '暂无数据' }}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnifiedFinancialTable = memo(UnifiedFinancialTableInner);
|
||||||
|
export default UnifiedFinancialTable;
|
||||||
@@ -17,3 +17,4 @@ export { StockComparison } from './StockComparison';
|
|||||||
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
||||||
export { MetricChartModal } from './MetricChartModal';
|
export { MetricChartModal } from './MetricChartModal';
|
||||||
export type { MetricChartModalProps } from './MetricChartModal';
|
export type { MetricChartModalProps } from './MetricChartModal';
|
||||||
|
export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
|
|
||||||
// 通用组件
|
// 通用组件
|
||||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||||
import LoadingState from '../LoadingState';
|
|
||||||
|
|
||||||
// 内部模块导入
|
// 内部模块导入
|
||||||
import { useFinancialData, type DataTypeKey } from './hooks';
|
import { useFinancialData, type DataTypeKey } from './hooks';
|
||||||
@@ -42,6 +41,7 @@ import {
|
|||||||
MainBusinessAnalysis,
|
MainBusinessAnalysis,
|
||||||
ComparisonAnalysis,
|
ComparisonAnalysis,
|
||||||
MetricChartModal,
|
MetricChartModal,
|
||||||
|
FinancialPanoramaSkeleton,
|
||||||
} from './components';
|
} from './components';
|
||||||
import {
|
import {
|
||||||
BalanceSheetTab,
|
BalanceSheetTab,
|
||||||
@@ -172,26 +172,31 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 初始加载显示骨架屏
|
||||||
|
if (loading && !stockInfo) {
|
||||||
|
return (
|
||||||
|
<Container maxW="container.xl" py={5}>
|
||||||
|
<FinancialPanoramaSkeleton />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl" py={5}>
|
<Container maxW="container.xl" py={5}>
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||||
{loading ? (
|
|
||||||
<LoadingState message="加载财务数据中..." height="300px" variant="skeleton" skeletonRows={6} />
|
|
||||||
) : (
|
|
||||||
<FinancialOverviewPanel
|
<FinancialOverviewPanel
|
||||||
stockInfo={stockInfo}
|
stockInfo={stockInfo}
|
||||||
financialMetrics={financialMetrics}
|
financialMetrics={financialMetrics}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 营收与利润趋势 */}
|
{/* 营收与利润趋势 */}
|
||||||
{!loading && comparison && comparison.length > 0 && (
|
{comparison && comparison.length > 0 && (
|
||||||
<ComparisonAnalysis comparison={comparison} />
|
<ComparisonAnalysis comparison={comparison} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主营业务 */}
|
{/* 主营业务 */}
|
||||||
{!loading && stockInfo && (
|
{stockInfo && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
||||||
主营业务
|
主营业务
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* 统一的财务 Tab 组件
|
||||||
|
*
|
||||||
|
* 使用 UnifiedFinancialTable 实现所有 10 个财务表格:
|
||||||
|
* - 7 个财务指标分类 Tab
|
||||||
|
* - 3 个财务报表 Tab
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { UnifiedFinancialTable } from '../components/UnifiedFinancialTable';
|
||||||
|
import {
|
||||||
|
FINANCIAL_METRICS_CATEGORIES,
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
EQUITY_METRICS,
|
||||||
|
INCOME_STATEMENT_SECTIONS,
|
||||||
|
CASHFLOW_METRICS,
|
||||||
|
} from '../constants';
|
||||||
|
import type { FinancialMetricsData, BalanceSheetData, IncomeStatementData, CashflowData } from '../types';
|
||||||
|
|
||||||
|
// ==================== 通用 Props 类型 ====================
|
||||||
|
|
||||||
|
/** 财务指标 Tab Props */
|
||||||
|
export interface MetricsTabProps {
|
||||||
|
financialMetrics: FinancialMetricsData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
showMetricChart: (name: string, key: string, data: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资产负债表 Tab Props */
|
||||||
|
export interface BalanceSheetTabProps {
|
||||||
|
balanceSheet: BalanceSheetData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 利润表 Tab Props */
|
||||||
|
export interface IncomeStatementTabProps {
|
||||||
|
incomeStatement: IncomeStatementData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 现金流量表 Tab Props */
|
||||||
|
export interface CashflowTabProps {
|
||||||
|
cashflow: CashflowData[];
|
||||||
|
loading?: boolean;
|
||||||
|
loadingTab?: string | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 财务指标 Tab (7个) ====================
|
||||||
|
|
||||||
|
/** 盈利能力 Tab */
|
||||||
|
export const ProfitabilityTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.profitability;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="profitability"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ProfitabilityTab.displayName = 'ProfitabilityTab';
|
||||||
|
|
||||||
|
/** 每股指标 Tab */
|
||||||
|
export const PerShareTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.perShare;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="perShare"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
PerShareTab.displayName = 'PerShareTab';
|
||||||
|
|
||||||
|
/** 成长能力 Tab */
|
||||||
|
export const GrowthTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.growth;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="growth"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
isGrowthCategory
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
GrowthTab.displayName = 'GrowthTab';
|
||||||
|
|
||||||
|
/** 运营效率 Tab */
|
||||||
|
export const OperationalTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.operational;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="operational"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
OperationalTab.displayName = 'OperationalTab';
|
||||||
|
|
||||||
|
/** 偿债能力 Tab */
|
||||||
|
export const SolvencyTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.solvency;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="solvency"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SolvencyTab.displayName = 'SolvencyTab';
|
||||||
|
|
||||||
|
/** 费用率 Tab */
|
||||||
|
export const ExpenseTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.expense;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="expense"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ExpenseTab.displayName = 'ExpenseTab';
|
||||||
|
|
||||||
|
/** 现金流指标 Tab */
|
||||||
|
export const CashflowMetricsTab = memo<MetricsTabProps>(({ financialMetrics, loading, showMetricChart }) => {
|
||||||
|
const category = FINANCIAL_METRICS_CATEGORIES.cashflow;
|
||||||
|
return (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="metrics"
|
||||||
|
data={financialMetrics}
|
||||||
|
categoryKey="cashflow"
|
||||||
|
categoryTitle={category.title}
|
||||||
|
metrics={category.metrics}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
CashflowMetricsTab.displayName = 'CashflowMetricsTab';
|
||||||
|
|
||||||
|
// ==================== 财务报表 Tab (3个) ====================
|
||||||
|
|
||||||
|
// 资产负债表分组配置
|
||||||
|
const BALANCE_SHEET_SECTIONS = [
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
EQUITY_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 资产负债表 Tab */
|
||||||
|
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={balanceSheet}
|
||||||
|
sections={BALANCE_SHEET_SECTIONS}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
BalanceSheetTab.displayName = 'BalanceSheetTab';
|
||||||
|
|
||||||
|
/** 利润表 Tab */
|
||||||
|
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={incomeStatement}
|
||||||
|
sections={INCOME_STATEMENT_SECTIONS}
|
||||||
|
hideTotalSectionTitle={false}
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
IncomeStatementTab.displayName = 'IncomeStatementTab';
|
||||||
|
|
||||||
|
// 现金流量表配置(转换为 sections 格式)
|
||||||
|
const CASHFLOW_SECTIONS = [{
|
||||||
|
title: '现金流量',
|
||||||
|
key: 'cashflow',
|
||||||
|
metrics: CASHFLOW_METRICS.map(m => ({
|
||||||
|
...m,
|
||||||
|
isCore: ['operating_net', 'free_cash_flow'].includes(m.key),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
|
||||||
|
/** 现金流量表 Tab */
|
||||||
|
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, showMetricChart }) => (
|
||||||
|
<UnifiedFinancialTable
|
||||||
|
type="statement"
|
||||||
|
data={cashflow}
|
||||||
|
sections={CASHFLOW_SECTIONS}
|
||||||
|
hideTotalSectionTitle
|
||||||
|
showMetricChart={showMetricChart}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CashflowTab.displayName = 'CashflowTab';
|
||||||
@@ -126,6 +126,10 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 立即清空旧数据,触发骨架屏显示
|
||||||
|
setQuoteData(null);
|
||||||
|
setBasicInfo(null);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user