feat(ForecastReport): 添加盈利预测骨架屏

- 创建 ForecastSkeleton 组件(图表卡片 + 表格)
- 初始加载时显示骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 15:18:00 +08:00
parent 27b0e9375a
commit 6eec7c6402
5 changed files with 90 additions and 272 deletions

View File

@@ -19,11 +19,14 @@ import type { MetricConfig, MetricSectionConfig } from '../types';
export type TableType = 'metrics' | 'statement';
// 数据类型:必须有 period 字段
export type FinancialDataItem = { period: string; [key: string]: unknown };
export interface UnifiedFinancialTableProps {
/** 表格类型: metrics=指标表格, statement=报表表格 */
type: TableType;
/** 数据数组 */
data: Array<{ period: string; [key: string]: unknown }>;
data: FinancialDataItem[];
/** metrics 类型: 指标分类 key */
categoryKey?: string;
/** metrics 类型: 分类标题 */
@@ -35,7 +38,7 @@ export interface UnifiedFinancialTableProps {
/** 是否隐藏汇总行的分组标题 */
hideTotalSectionTitle?: boolean;
/** 点击行显示图表回调 */
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
/** 是否为成长类指标(正负值着色) */
isGrowthCategory?: boolean;
/** 核心指标 keys用于 cashflow 表格) */
@@ -78,7 +81,6 @@ const extendedTableStyles = getTableStyles(TABLE_CLASS_NAME) + `
const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
type,
data,
categoryKey,
categoryTitle,
metrics,
sections,
@@ -107,7 +109,7 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
}
// 限制显示列数
const maxColumns = type === 'metrics' ? 6 : (type === 'statement' ? 8 : 6);
const maxColumns = type === 'metrics' ? 6 : 8;
const displayData = data.slice(0, Math.min(data.length, maxColumns));
// 构建表格数据
@@ -168,7 +170,7 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
}
return rows;
}, [type, metrics, sections, displayData, data, hideTotalSectionTitle, coreMetricKeys]);
}, [type, metrics, sections, displayData, hideTotalSectionTitle, coreMetricKeys]);
// 计算同比变化
const calcYoY = (

View File

@@ -1,264 +0,0 @@
/**
* 统一的财务 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';

View File

@@ -0,0 +1,79 @@
/**
* 盈利预测骨架屏组件
*/
import React, { memo } from 'react';
import {
Box,
SimpleGrid,
Skeleton,
SkeletonText,
Card,
CardBody,
VStack,
} from '@chakra-ui/react';
// 黑金主题配色
const SKELETON_COLORS = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
/**
* 图表卡片骨架屏
*/
const ChartCardSkeleton: React.FC = memo(() => (
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody>
<Skeleton height="20px" width="100px" mb={4} {...SKELETON_COLORS} />
<Skeleton height="200px" borderRadius="md" {...SKELETON_COLORS} />
</CardBody>
</Card>
));
ChartCardSkeleton.displayName = 'ChartCardSkeleton';
/**
* 表格骨架屏
*/
const TableSkeleton: 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} />
<VStack align="stretch" spacing={3}>
{/* 表头 */}
<Skeleton height="40px" {...SKELETON_COLORS} />
{/* 表格行 */}
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} height="36px" {...SKELETON_COLORS} />
))}
</VStack>
</CardBody>
</Card>
));
TableSkeleton.displayName = 'TableSkeleton';
/**
* 盈利预测完整骨架屏
*/
const ForecastSkeleton: React.FC = memo(() => (
<Box>
{/* 图表区域 - 3列布局 */}
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
<ChartCardSkeleton />
<ChartCardSkeleton />
<ChartCardSkeleton />
</SimpleGrid>
{/* 详细数据表格 */}
<Box mt={4}>
<TableSkeleton />
</Box>
</Box>
));
ForecastSkeleton.displayName = 'ForecastSkeleton';
export { ForecastSkeleton };
export default ForecastSkeleton;

View File

@@ -9,3 +9,4 @@ export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
export { default as EpsChart } from './EpsChart';
export { default as PePegChart } from './PePegChart';
export { default as DetailTable } from './DetailTable';
export { ForecastSkeleton } from './ForecastSkeleton';

View File

@@ -11,16 +11,16 @@ import {
EpsChart,
PePegChart,
DetailTable,
ForecastSkeleton,
} from './components';
import LoadingState from '../LoadingState';
import type { ForecastReportProps } from './types';
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
const { data, isLoading, error, refetch } = useForecastData(stockCode);
// 加载状态
// 加载状态 - 显示骨架屏
if (isLoading && !data) {
return <LoadingState message="加载盈利预测数据中..." height="300px" />;
return <ForecastSkeleton />;
}
// 错误状态