diff --git a/src/views/Company/components/FinancialPanorama/components/FinancialPanoramaSkeleton.tsx b/src/views/Company/components/FinancialPanorama/components/FinancialPanoramaSkeleton.tsx new file mode 100644 index 00000000..fe22c631 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/FinancialPanoramaSkeleton.tsx @@ -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(() => ( + + + + {[1, 2, 3].map((i) => ( + + + + {[1, 2, 3, 4].map((j) => ( + + + + + ))} + + + ))} + + + +)); + +OverviewPanelSkeleton.displayName = 'OverviewPanelSkeleton'; + +/** + * 图表区域骨架屏 + */ +const ChartSkeleton: React.FC = memo(() => ( + + + + + + +)); + +ChartSkeleton.displayName = 'ChartSkeleton'; + +/** + * 主营业务骨架屏 + */ +const MainBusinessSkeleton: React.FC = memo(() => ( + + + + {[1, 2].map((i) => ( + + + + + {[1, 2, 3].map((j) => ( + + + + + ))} + + + + ))} + + +)); + +MainBusinessSkeleton.displayName = 'MainBusinessSkeleton'; + +/** + * Tab 区域骨架屏 + */ +const TabSkeleton: React.FC = memo(() => ( + + + {/* Tab 栏 */} + + {[1, 2, 3, 4, 5, 6, 7].map((i) => ( + + ))} + + {/* Tab 内容 */} + + + + + +)); + +TabSkeleton.displayName = 'TabSkeleton'; + +/** + * 财务全景完整骨架屏 + */ +const FinancialPanoramaSkeleton: React.FC = memo(() => ( + + + + + + +)); + +FinancialPanoramaSkeleton.displayName = 'FinancialPanoramaSkeleton'; + +export { FinancialPanoramaSkeleton }; +export default FinancialPanoramaSkeleton; diff --git a/src/views/Company/components/FinancialPanorama/components/UnifiedFinancialTable.tsx b/src/views/Company/components/FinancialPanorama/components/UnifiedFinancialTable.tsx new file mode 100644 index 00000000..ed6a1fcc --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/components/UnifiedFinancialTable.tsx @@ -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 = ({ + type, + data, + categoryKey, + categoryTitle, + metrics, + sections, + hideTotalSectionTitle = true, + showMetricChart, + isGrowthCategory = false, + coreMetricKeys = [], + loading = false, +}) => { + // 加载中状态 + if (loading && (!Array.isArray(data) || data.length === 0)) { + return ( +
+ +
+ ); + } + + // 数据安全检查 + if (!Array.isArray(data) || data.length === 0) { + return ( + + 暂无数据 + + ); + } + + // 限制显示列数 + 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(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(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 = useMemo(() => { + const cols: ColumnsType = [ + // 指标名称列 + { + title: type === 'metrics' ? (categoryTitle || '指标') : '项目', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: type === 'metrics' ? 200 : 250, + render: (name: string, record: TableRowData) => { + if (record.isSection) { + return {name}; + } + return ( + + + {name} + + {record.isCore && ( + + 核心 + + )} + + ); + }, + }, + // 各期数据列 + ...displayData.map((item) => ({ + title: ( + + {formatUtils.getReportType(item.period)} + {item.period.substring(0, 10)} + + ), + 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 ( + + {record.name}: {type === 'metrics' ? (value?.toFixed(2) || '-') : formatUtils.formatLargeNumber(value)} + {yoy !== null && 同比: {yoy.toFixed(2)}%} + + } + > + + + {formattedValue} + + {showYoyArrow && value !== undefined && Math.abs(value) > 0.01 && ( + + {yoy > 0 ? '↑' : '↓'}{type === 'statement' && Math.abs(yoy) < 100 ? `${Math.abs(yoy).toFixed(0)}%` : ''} + + )} + + + ); + }, + })), + // 操作列 + { + title: '', + key: 'action', + width: type === 'metrics' ? 40 : 80, + fixed: 'right', + render: (_: unknown, record: TableRowData) => { + if (record.isSection) return null; + + if (type === 'metrics') { + return ( + { + e.stopPropagation(); + showMetricChart(record.name, record.key, data, record.path); + }} + /> + ); + } + + return ( + + ); + }, + }, + ]; + + return cols; + }, [type, displayData, data, showMetricChart, categoryTitle, isGrowthCategory]); + + return ( + + + + { + 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: '暂无数据' }} + /> + + + ); +}; + +export const UnifiedFinancialTable = memo(UnifiedFinancialTableInner); +export default UnifiedFinancialTable; diff --git a/src/views/Company/components/FinancialPanorama/components/index.ts b/src/views/Company/components/FinancialPanorama/components/index.ts index e2d002c1..95e50c44 100644 --- a/src/views/Company/components/FinancialPanorama/components/index.ts +++ b/src/views/Company/components/FinancialPanorama/components/index.ts @@ -17,3 +17,4 @@ export { StockComparison } from './StockComparison'; export { ComparisonAnalysis } from './ComparisonAnalysis'; export { MetricChartModal } from './MetricChartModal'; export type { MetricChartModalProps } from './MetricChartModal'; +export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton'; diff --git a/src/views/Company/components/FinancialPanorama/index.tsx b/src/views/Company/components/FinancialPanorama/index.tsx index a9a7f349..1ab72aa6 100644 --- a/src/views/Company/components/FinancialPanorama/index.tsx +++ b/src/views/Company/components/FinancialPanorama/index.tsx @@ -30,7 +30,6 @@ import { // 通用组件 import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer'; -import LoadingState from '../LoadingState'; // 内部模块导入 import { useFinancialData, type DataTypeKey } from './hooks'; @@ -42,6 +41,7 @@ import { MainBusinessAnalysis, ComparisonAnalysis, MetricChartModal, + FinancialPanoramaSkeleton, } from './components'; import { BalanceSheetTab, @@ -172,26 +172,31 @@ const FinancialPanorama: React.FC = ({ stockCode: propSt ] ); + // 初始加载显示骨架屏 + if (loading && !stockInfo) { + return ( + + + + ); + } + return ( {/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */} - {loading ? ( - - ) : ( - - )} + {/* 营收与利润趋势 */} - {!loading && comparison && comparison.length > 0 && ( + {comparison && comparison.length > 0 && ( )} {/* 主营业务 */} - {!loading && stockInfo && ( + {stockInfo && ( 主营业务 diff --git a/src/views/Company/components/FinancialPanorama/tabs/UnifiedTabs.tsx b/src/views/Company/components/FinancialPanorama/tabs/UnifiedTabs.tsx new file mode 100644 index 00000000..6a046f02 --- /dev/null +++ b/src/views/Company/components/FinancialPanorama/tabs/UnifiedTabs.tsx @@ -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(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.profitability; + return ( + + ); +}); +ProfitabilityTab.displayName = 'ProfitabilityTab'; + +/** 每股指标 Tab */ +export const PerShareTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.perShare; + return ( + + ); +}); +PerShareTab.displayName = 'PerShareTab'; + +/** 成长能力 Tab */ +export const GrowthTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.growth; + return ( + + ); +}); +GrowthTab.displayName = 'GrowthTab'; + +/** 运营效率 Tab */ +export const OperationalTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.operational; + return ( + + ); +}); +OperationalTab.displayName = 'OperationalTab'; + +/** 偿债能力 Tab */ +export const SolvencyTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.solvency; + return ( + + ); +}); +SolvencyTab.displayName = 'SolvencyTab'; + +/** 费用率 Tab */ +export const ExpenseTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.expense; + return ( + + ); +}); +ExpenseTab.displayName = 'ExpenseTab'; + +/** 现金流指标 Tab */ +export const CashflowMetricsTab = memo(({ financialMetrics, loading, showMetricChart }) => { + const category = FINANCIAL_METRICS_CATEGORIES.cashflow; + return ( + + ); +}); +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(({ balanceSheet, loading, showMetricChart }) => ( + +)); +BalanceSheetTab.displayName = 'BalanceSheetTab'; + +/** 利润表 Tab */ +export const IncomeStatementTab = memo(({ incomeStatement, loading, showMetricChart }) => ( + +)); +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(({ cashflow, loading, showMetricChart }) => ( + +)); +CashflowTab.displayName = 'CashflowTab'; diff --git a/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts b/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts index 4de99506..5f6f943e 100644 --- a/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts +++ b/src/views/Company/components/StockQuoteCard/hooks/useStockQuoteData.ts @@ -126,6 +126,10 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult = return; } + // 立即清空旧数据,触发骨架屏显示 + setQuoteData(null); + setBasicInfo(null); + const controller = new AbortController(); let isCancelled = false;