feat(ForecastReport): 添加盈利预测骨架屏
- 创建 ForecastSkeleton 组件(图表卡片 + 表格) - 初始加载时显示骨架屏 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,14 @@ import type { MetricConfig, MetricSectionConfig } from '../types';
|
|||||||
|
|
||||||
export type TableType = 'metrics' | 'statement';
|
export type TableType = 'metrics' | 'statement';
|
||||||
|
|
||||||
|
// 数据类型:必须有 period 字段
|
||||||
|
export type FinancialDataItem = { period: string; [key: string]: unknown };
|
||||||
|
|
||||||
export interface UnifiedFinancialTableProps {
|
export interface UnifiedFinancialTableProps {
|
||||||
/** 表格类型: metrics=指标表格, statement=报表表格 */
|
/** 表格类型: metrics=指标表格, statement=报表表格 */
|
||||||
type: TableType;
|
type: TableType;
|
||||||
/** 数据数组 */
|
/** 数据数组 */
|
||||||
data: Array<{ period: string; [key: string]: unknown }>;
|
data: FinancialDataItem[];
|
||||||
/** metrics 类型: 指标分类 key */
|
/** metrics 类型: 指标分类 key */
|
||||||
categoryKey?: string;
|
categoryKey?: string;
|
||||||
/** metrics 类型: 分类标题 */
|
/** metrics 类型: 分类标题 */
|
||||||
@@ -35,7 +38,7 @@ export interface UnifiedFinancialTableProps {
|
|||||||
/** 是否隐藏汇总行的分组标题 */
|
/** 是否隐藏汇总行的分组标题 */
|
||||||
hideTotalSectionTitle?: boolean;
|
hideTotalSectionTitle?: boolean;
|
||||||
/** 点击行显示图表回调 */
|
/** 点击行显示图表回调 */
|
||||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
showMetricChart: (name: string, key: string, data: FinancialDataItem[], path: string) => void;
|
||||||
/** 是否为成长类指标(正负值着色) */
|
/** 是否为成长类指标(正负值着色) */
|
||||||
isGrowthCategory?: boolean;
|
isGrowthCategory?: boolean;
|
||||||
/** 核心指标 keys(用于 cashflow 表格) */
|
/** 核心指标 keys(用于 cashflow 表格) */
|
||||||
@@ -78,7 +81,6 @@ const extendedTableStyles = getTableStyles(TABLE_CLASS_NAME) + `
|
|||||||
const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
categoryKey,
|
|
||||||
categoryTitle,
|
categoryTitle,
|
||||||
metrics,
|
metrics,
|
||||||
sections,
|
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));
|
const displayData = data.slice(0, Math.min(data.length, maxColumns));
|
||||||
|
|
||||||
// 构建表格数据
|
// 构建表格数据
|
||||||
@@ -168,7 +170,7 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [type, metrics, sections, displayData, data, hideTotalSectionTitle, coreMetricKeys]);
|
}, [type, metrics, sections, displayData, hideTotalSectionTitle, coreMetricKeys]);
|
||||||
|
|
||||||
// 计算同比变化
|
// 计算同比变化
|
||||||
const calcYoY = (
|
const calcYoY = (
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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;
|
||||||
@@ -9,3 +9,4 @@ export { default as IncomeProfitGrowthChart } from './IncomeProfitGrowthChart';
|
|||||||
export { default as EpsChart } from './EpsChart';
|
export { default as EpsChart } from './EpsChart';
|
||||||
export { default as PePegChart } from './PePegChart';
|
export { default as PePegChart } from './PePegChart';
|
||||||
export { default as DetailTable } from './DetailTable';
|
export { default as DetailTable } from './DetailTable';
|
||||||
|
export { ForecastSkeleton } from './ForecastSkeleton';
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import {
|
|||||||
EpsChart,
|
EpsChart,
|
||||||
PePegChart,
|
PePegChart,
|
||||||
DetailTable,
|
DetailTable,
|
||||||
|
ForecastSkeleton,
|
||||||
} from './components';
|
} from './components';
|
||||||
import LoadingState from '../LoadingState';
|
|
||||||
import type { ForecastReportProps } from './types';
|
import type { ForecastReportProps } from './types';
|
||||||
|
|
||||||
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
|
const ForecastReport: React.FC<ForecastReportProps> = ({ stockCode }) => {
|
||||||
const { data, isLoading, error, refetch } = useForecastData(stockCode);
|
const { data, isLoading, error, refetch } = useForecastData(stockCode);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态 - 显示骨架屏
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return <LoadingState message="加载盈利预测数据中..." height="300px" />;
|
return <ForecastSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态
|
||||||
|
|||||||
Reference in New Issue
Block a user