Files
vf_react/src/views/Company/FinancialPanorama.js
2025-12-02 18:55:59 +08:00

2154 lines
87 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Company/FinancialPanorama.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { logger } from '../../utils/logger';
import {
Box,
Container,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
Card,
CardBody,
CardHeader,
Spinner,
Center,
Alert,
AlertIcon,
Badge,
VStack,
HStack,
Divider,
useColorModeValue,
Select,
Button,
Tooltip,
Progress,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Input,
Flex,
Tag,
TagLabel,
IconButton,
useToast,
Skeleton,
SkeletonText,
Grid,
GridItem,
ButtonGroup,
Stack,
Collapse,
useColorMode,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
ChevronUpIcon,
InfoIcon,
DownloadIcon,
RepeatIcon,
SearchIcon,
ViewIcon,
TimeIcon,
ArrowUpIcon,
ArrowDownIcon,
} from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { financialService, formatUtils, chartUtils } from '../../services/financialService';
const FinancialPanorama = ({ stockCode: propStockCode }) => {
// 状态管理
const [stockCode, setStockCode] = useState(propStockCode || '600000');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(8);
const [activeTab, setActiveTab] = useState(0);
// 财务数据状态
const [stockInfo, setStockInfo] = useState(null);
const [balanceSheet, setBalanceSheet] = useState([]);
const [incomeStatement, setIncomeStatement] = useState([]);
const [cashflow, setCashflow] = useState([]);
const [financialMetrics, setFinancialMetrics] = useState([]);
const [mainBusiness, setMainBusiness] = useState(null);
const [forecast, setForecast] = useState(null);
const [industryRank, setIndustryRank] = useState([]);
const [comparison, setComparison] = useState([]);
// UI状态
const { isOpen, onOpen, onClose } = useDisclosure();
const [modalContent, setModalContent] = useState(null);
const [expandedRows, setExpandedRows] = useState({});
const toast = useToast();
const { colorMode } = useColorMode();
// 颜色配置(中国市场:红涨绿跌)
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const positiveColor = useColorModeValue('red.500', 'red.400'); // 红涨
const negativeColor = useColorModeValue('green.500', 'green.400'); // 绿跌
// 加载所有财务数据
const loadFinancialData = async () => {
if (!stockCode || stockCode.length !== 6) {
logger.warn('FinancialPanorama', 'loadFinancialData', '无效的股票代码', { stockCode });
toast({
title: '请输入有效的6位股票代码',
status: 'warning',
duration: 3000,
});
return;
}
logger.debug('FinancialPanorama', '开始加载财务数据', { stockCode, selectedPeriods });
setLoading(true);
setError(null);
try {
// 并行加载所有数据
const [
stockInfoRes,
balanceRes,
incomeRes,
cashflowRes,
metricsRes,
businessRes,
forecastRes,
rankRes,
comparisonRes
] = await Promise.all([
financialService.getStockInfo(stockCode),
financialService.getBalanceSheet(stockCode, selectedPeriods),
financialService.getIncomeStatement(stockCode, selectedPeriods),
financialService.getCashflow(stockCode, selectedPeriods),
financialService.getFinancialMetrics(stockCode, selectedPeriods),
financialService.getMainBusiness(stockCode, 4),
financialService.getForecast(stockCode),
financialService.getIndustryRank(stockCode, 4),
financialService.getPeriodComparison(stockCode, selectedPeriods)
]);
// 设置数据
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
if (balanceRes.success) setBalanceSheet(balanceRes.data);
if (incomeRes.success) setIncomeStatement(incomeRes.data);
if (cashflowRes.success) setCashflow(cashflowRes.data);
if (metricsRes.success) setFinancialMetrics(metricsRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
if (forecastRes.success) setForecast(forecastRes.data);
if (rankRes.success) setIndustryRank(rankRes.data);
if (comparisonRes.success) setComparison(comparisonRes.data);
// ❌ 移除数据加载成功toast
logger.info('FinancialPanorama', '财务数据加载成功', { stockCode });
} catch (err) {
setError(err.message);
logger.error('FinancialPanorama', 'loadFinancialData', err, { stockCode, selectedPeriods });
// ❌ 移除数据加载失败toast
// toast({ title: '数据加载失败', description: err.message, status: 'error', duration: 5000 });
} finally {
setLoading(false);
}
};
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode, stockCode]);
// 初始加载
useEffect(() => {
if (stockCode) {
loadFinancialData();
}
}, [stockCode, selectedPeriods]);
// 计算同比变化率
const calculateYoYChange = (currentValue, currentPeriod, allData, metricPath) => {
if (!currentValue || !currentPeriod) return { change: 0, intensity: 0 };
// 找到去年同期的数据
const currentDate = new Date(currentPeriod);
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth() + 1;
// 查找去年同期
const lastYearSamePeriod = allData.find(item => {
const itemDate = new Date(item.period);
const itemYear = itemDate.getFullYear();
const itemMonth = itemDate.getMonth() + 1;
return itemYear === currentYear - 1 && itemMonth === currentMonth;
});
if (!lastYearSamePeriod) return { change: 0, intensity: 0 };
const previousValue = metricPath.split('.').reduce((obj, key) => obj?.[key], lastYearSamePeriod);
if (!previousValue || previousValue === 0) return { change: 0, intensity: 0 };
const change = ((currentValue - previousValue) / Math.abs(previousValue)) * 100;
const intensity = Math.min(Math.abs(change) / 50, 1); // 50%变化达到最大强度
return { change, intensity };
};
// 获取单元格背景色(中国市场颜色)
const getCellBackground = (change, intensity) => {
if (change > 0) {
return `rgba(239, 68, 68, ${intensity * 0.15})`; // 红色背景,涨
} else if (change < 0) {
return `rgba(34, 197, 94, ${intensity * 0.15})`; // 绿色背景,跌
}
return 'transparent';
};
// 点击指标行显示图表
const showMetricChart = (metricName, metricKey, data, dataPath) => {
const chartData = data.map(item => {
const value = dataPath.split('.').reduce((obj, key) => obj?.[key], item);
return {
period: formatUtils.getReportType(item.period),
date: item.period,
value: value
};
}).reverse();
const option = {
title: {
text: metricName,
left: 'center'
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
const value = params[0].value;
const formattedValue = value > 10000 ?
formatUtils.formatLargeNumber(value) :
value?.toFixed(2);
return `${params[0].name}<br/>${metricName}: ${formattedValue}`;
}
},
xAxis: {
type: 'category',
data: chartData.map(d => d.period),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value) => {
if (Math.abs(value) >= 100000000) {
return (value / 100000000).toFixed(0) + '亿';
} else if (Math.abs(value) >= 10000) {
return (value / 10000).toFixed(0) + '万';
}
return value.toFixed(0);
}
}
},
series: [{
type: 'bar',
data: chartData.map(d => d.value),
itemStyle: {
color: (params) => {
const idx = params.dataIndex;
if (idx === 0) return '#3182CE';
const prevValue = chartData[idx - 1].value;
const currValue = params.value;
// 中国市场颜色:红涨绿跌
return currValue >= prevValue ? '#EF4444' : '#10B981';
}
},
label: {
show: true,
position: 'top',
formatter: (params) => {
const value = params.value;
if (Math.abs(value) >= 100000000) {
return (value / 100000000).toFixed(1) + '亿';
} else if (Math.abs(value) >= 10000) {
return (value / 10000).toFixed(1) + '万';
} else if (Math.abs(value) >= 1) {
return value.toFixed(1);
}
return value.toFixed(2);
}
}
}]
};
setModalContent(
<Box>
<ReactECharts
option={option}
style={{ height: '400px', width: '100%' }}
/>
<Divider my={4} />
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>报告期</Th>
<Th isNumeric>数值</Th>
<Th isNumeric>同比</Th>
<Th isNumeric>环比</Th>
</Tr>
</Thead>
<Tbody>
{chartData.map((item, idx) => {
// 计算环比
const qoq = idx > 0 ?
((item.value - chartData[idx - 1].value) / Math.abs(chartData[idx - 1].value) * 100) : null;
// 计算同比
const currentDate = new Date(item.date);
const lastYearItem = chartData.find(d => {
const date = new Date(d.date);
return date.getFullYear() === currentDate.getFullYear() - 1 &&
date.getMonth() === currentDate.getMonth();
});
const yoy = lastYearItem ?
((item.value - lastYearItem.value) / Math.abs(lastYearItem.value) * 100) : null;
return (
<Tr key={idx}>
<Td>{item.period}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.value)}</Td>
<Td isNumeric color={yoy > 0 ? positiveColor : yoy < 0 ? negativeColor : 'gray.500'}>
{yoy ? `${yoy.toFixed(2)}%` : '-'}
</Td>
<Td isNumeric color={qoq > 0 ? positiveColor : qoq < 0 ? negativeColor : 'gray.500'}>
{qoq ? `${qoq.toFixed(2)}%` : '-'}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
);
onOpen();
};
// 资产负债表组件 - 完整版
const BalanceSheetTable = () => {
const [expandedSections, setExpandedSections] = useState({
currentAssets: true,
nonCurrentAssets: true,
currentLiabilities: true,
nonCurrentLiabilities: true,
equity: true
});
const toggleSection = (section) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
// 完整的资产负债表指标
const assetSections = [
{
title: '流动资产',
key: 'currentAssets',
metrics: [
{ name: '货币资金', key: 'cash', path: 'assets.current_assets.cash', isCore: true },
{ name: '交易性金融资产', key: 'trading_financial_assets', path: 'assets.current_assets.trading_financial_assets' },
{ name: '应收票据', key: 'notes_receivable', path: 'assets.current_assets.notes_receivable' },
{ name: '应收账款', key: 'accounts_receivable', path: 'assets.current_assets.accounts_receivable', isCore: true },
{ name: '预付款项', key: 'prepayments', path: 'assets.current_assets.prepayments' },
{ name: '其他应收款', key: 'other_receivables', path: 'assets.current_assets.other_receivables' },
{ name: '存货', key: 'inventory', path: 'assets.current_assets.inventory', isCore: true },
{ name: '合同资产', key: 'contract_assets', path: 'assets.current_assets.contract_assets' },
{ name: '其他流动资产', key: 'other_current_assets', path: 'assets.current_assets.other_current_assets' },
{ name: '流动资产合计', key: 'total_current_assets', path: 'assets.current_assets.total', isTotal: true },
]
},
{
title: '非流动资产',
key: 'nonCurrentAssets',
metrics: [
{ name: '长期股权投资', key: 'long_term_equity_investments', path: 'assets.non_current_assets.long_term_equity_investments' },
{ name: '投资性房地产', key: 'investment_property', path: 'assets.non_current_assets.investment_property' },
{ name: '固定资产', key: 'fixed_assets', path: 'assets.non_current_assets.fixed_assets', isCore: true },
{ name: '在建工程', key: 'construction_in_progress', path: 'assets.non_current_assets.construction_in_progress' },
{ name: '使用权资产', key: 'right_of_use_assets', path: 'assets.non_current_assets.right_of_use_assets' },
{ name: '无形资产', key: 'intangible_assets', path: 'assets.non_current_assets.intangible_assets', isCore: true },
{ name: '商誉', key: 'goodwill', path: 'assets.non_current_assets.goodwill', isCore: true },
{ name: '递延所得税资产', key: 'deferred_tax_assets', path: 'assets.non_current_assets.deferred_tax_assets' },
{ name: '其他非流动资产', key: 'other_non_current_assets', path: 'assets.non_current_assets.other_non_current_assets' },
{ name: '非流动资产合计', key: 'total_non_current_assets', path: 'assets.non_current_assets.total', isTotal: true },
]
},
{
title: '资产总计',
key: 'totalAssets',
metrics: [
{ name: '资产总计', key: 'total_assets', path: 'assets.total', isTotal: true, isCore: true },
]
}
];
const liabilitySections = [
{
title: '流动负债',
key: 'currentLiabilities',
metrics: [
{ name: '短期借款', key: 'short_term_borrowings', path: 'liabilities.current_liabilities.short_term_borrowings', isCore: true },
{ name: '应付票据', key: 'notes_payable', path: 'liabilities.current_liabilities.notes_payable' },
{ name: '应付账款', key: 'accounts_payable', path: 'liabilities.current_liabilities.accounts_payable', isCore: true },
{ name: '预收款项', key: 'advance_receipts', path: 'liabilities.current_liabilities.advance_receipts' },
{ name: '合同负债', key: 'contract_liabilities', path: 'liabilities.current_liabilities.contract_liabilities' },
{ name: '应付职工薪酬', key: 'employee_compensation_payable', path: 'liabilities.current_liabilities.employee_compensation_payable' },
{ name: '应交税费', key: 'taxes_payable', path: 'liabilities.current_liabilities.taxes_payable' },
{ name: '其他应付款', key: 'other_payables', path: 'liabilities.current_liabilities.other_payables' },
{ name: '一年内到期的非流动负债', key: 'non_current_due_within_one_year', path: 'liabilities.current_liabilities.non_current_liabilities_due_within_one_year' },
{ name: '流动负债合计', key: 'total_current_liabilities', path: 'liabilities.current_liabilities.total', isTotal: true },
]
},
{
title: '非流动负债',
key: 'nonCurrentLiabilities',
metrics: [
{ name: '长期借款', key: 'long_term_borrowings', path: 'liabilities.non_current_liabilities.long_term_borrowings', isCore: true },
{ name: '应付债券', key: 'bonds_payable', path: 'liabilities.non_current_liabilities.bonds_payable' },
{ name: '租赁负债', key: 'lease_liabilities', path: 'liabilities.non_current_liabilities.lease_liabilities' },
{ name: '递延所得税负债', key: 'deferred_tax_liabilities', path: 'liabilities.non_current_liabilities.deferred_tax_liabilities' },
{ name: '其他非流动负债', key: 'other_non_current_liabilities', path: 'liabilities.non_current_liabilities.other_non_current_liabilities' },
{ name: '非流动负债合计', key: 'total_non_current_liabilities', path: 'liabilities.non_current_liabilities.total', isTotal: true },
]
},
{
title: '负债合计',
key: 'totalLiabilities',
metrics: [
{ name: '负债合计', key: 'total_liabilities', path: 'liabilities.total', isTotal: true, isCore: true },
]
}
];
const equitySections = [
{
title: '股东权益',
key: 'equity',
metrics: [
{ name: '股本', key: 'share_capital', path: 'equity.share_capital', isCore: true },
{ name: '资本公积', key: 'capital_reserve', path: 'equity.capital_reserve' },
{ name: '盈余公积', key: 'surplus_reserve', path: 'equity.surplus_reserve' },
{ name: '未分配利润', key: 'undistributed_profit', path: 'equity.undistributed_profit', isCore: true },
{ name: '库存股', key: 'treasury_stock', path: 'equity.treasury_stock' },
{ name: '其他综合收益', key: 'other_comprehensive_income', path: 'equity.other_comprehensive_income' },
{ name: '归属母公司股东权益', key: 'parent_company_equity', path: 'equity.parent_company_equity', isCore: true },
{ name: '少数股东权益', key: 'minority_interests', path: 'equity.minority_interests' },
{ name: '股东权益合计', key: 'total_equity', path: 'equity.total', isTotal: true, isCore: true },
]
}
];
// 数组安全检查
if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无资产负债表数据
</Alert>
);
}
const maxColumns = Math.min(balanceSheet.length, 6);
const displayData = balanceSheet.slice(0, maxColumns);
const renderSection = (sections, sectionType) => (
<>
{sections.map(section => (
<React.Fragment key={section.key}>
{section.title !== '资产总计' && section.title !== '负债合计' && (
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? <ChevronUpIcon /> : <ChevronDownIcon />}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
)}
{(expandedSections[section.key] || section.title === '资产总计' || section.title === '负债合计' || section.title === '股东权益合计') && section.metrics.map(metric => {
const rowData = balanceSheet.map(item => {
const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item);
return value;
});
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, balanceSheet, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal && <Box w={4} />}
<Text
fontWeight={metric.isTotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
>
{metric.name}
</Text>
{metric.isCore && <Badge size="xs" colorScheme="purple">核心</Badge>}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value,
item.period,
balanceSheet,
metric.path
);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
<Text>同比: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text fontSize="xs" fontWeight={metric.isTotal ? 'bold' : 'normal'}>
{formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, balanceSheet, metric.path);
}}
/>
</Td>
</Tr>
);
})}
</React.Fragment>
))}
</>
);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">项目</Th>
{displayData.map(item => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px">操作</Th>
</Tr>
</Thead>
<Tbody>
{renderSection(assetSections, 'assets')}
<Tr height={2} />
{renderSection(liabilitySections, 'liabilities')}
<Tr height={2} />
{renderSection(equitySections, 'equity')}
</Tbody>
</Table>
</TableContainer>
);
};
// 利润表组件 - 完整版
const IncomeStatementTable = () => {
const [expandedSections, setExpandedSections] = useState({
revenue: true,
costs: true,
profits: true,
eps: true
});
const toggleSection = (section) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const sections = [
{
title: '营业收入',
key: 'revenue',
metrics: [
{ name: '营业总收入', key: 'total_revenue', path: 'revenue.total_operating_revenue', isCore: true },
{ name: '营业收入', key: 'revenue', path: 'revenue.operating_revenue', isCore: true },
{ name: '其他业务收入', key: 'other_income', path: 'revenue.other_income' },
]
},
{
title: '营业成本与费用',
key: 'costs',
metrics: [
{ name: '营业总成本', key: 'total_cost', path: 'costs.total_operating_cost', isTotal: true },
{ name: '营业成本', key: 'cost', path: 'costs.operating_cost', isCore: true },
{ name: '税金及附加', key: 'taxes_and_surcharges', path: 'costs.taxes_and_surcharges' },
{ name: '销售费用', key: 'selling_expenses', path: 'costs.selling_expenses', isCore: true },
{ name: '管理费用', key: 'admin_expenses', path: 'costs.admin_expenses', isCore: true },
{ name: '研发费用', key: 'rd_expenses', path: 'costs.rd_expenses', isCore: true },
{ name: '财务费用', key: 'financial_expenses', path: 'costs.financial_expenses' },
{ name: ' 其中:利息费用', key: 'interest_expense', path: 'costs.interest_expense' },
{ name: '    利息收入', key: 'interest_income', path: 'costs.interest_income' },
{ name: '三费合计', key: 'three_expenses', path: 'costs.three_expenses_total', isSubtotal: true },
{ name: '四费合计(含研发)', key: 'four_expenses', path: 'costs.four_expenses_total', isSubtotal: true },
{ name: '资产减值损失', key: 'asset_impairment', path: 'costs.asset_impairment_loss' },
{ name: '信用减值损失', key: 'credit_impairment', path: 'costs.credit_impairment_loss' },
]
},
{
title: '其他收益',
key: 'otherGains',
metrics: [
{ name: '公允价值变动收益', key: 'fair_value_change', path: 'other_gains.fair_value_change' },
{ name: '投资收益', key: 'investment_income', path: 'other_gains.investment_income', isCore: true },
{ name: ' 其中:对联营企业和合营企业的投资收益', key: 'investment_income_associates', path: 'other_gains.investment_income_from_associates' },
{ name: '汇兑收益', key: 'exchange_income', path: 'other_gains.exchange_income' },
{ name: '资产处置收益', key: 'asset_disposal_income', path: 'other_gains.asset_disposal_income' },
]
},
{
title: '利润',
key: 'profits',
metrics: [
{ name: '营业利润', key: 'operating_profit', path: 'profit.operating_profit', isCore: true, isTotal: true },
{ name: '加:营业外收入', key: 'non_operating_income', path: 'non_operating.non_operating_income' },
{ name: '减:营业外支出', key: 'non_operating_expenses', path: 'non_operating.non_operating_expenses' },
{ name: '利润总额', key: 'total_profit', path: 'profit.total_profit', isCore: true, isTotal: true },
{ name: '减:所得税费用', key: 'income_tax', path: 'profit.income_tax_expense' },
{ name: '净利润', key: 'net_profit', path: 'profit.net_profit', isCore: true, isTotal: true },
{ name: ' 归属母公司所有者的净利润', key: 'parent_net_profit', path: 'profit.parent_net_profit', isCore: true },
{ name: ' 少数股东损益', key: 'minority_profit', path: 'profit.minority_profit' },
{ name: '持续经营净利润', key: 'continuing_net_profit', path: 'profit.continuing_operations_net_profit' },
{ name: '终止经营净利润', key: 'discontinued_net_profit', path: 'profit.discontinued_operations_net_profit' },
]
},
{
title: '每股收益',
key: 'eps',
metrics: [
{ name: '基本每股收益(元)', key: 'basic_eps', path: 'per_share.basic_eps', isCore: true },
{ name: '稀释每股收益(元)', key: 'diluted_eps', path: 'per_share.diluted_eps' },
]
},
{
title: '综合收益',
key: 'comprehensive',
metrics: [
{ name: '其他综合收益(税后)', key: 'other_comprehensive_income', path: 'comprehensive_income.other_comprehensive_income' },
{ name: '综合收益总额', key: 'total_comprehensive_income', path: 'comprehensive_income.total_comprehensive_income', isTotal: true },
{ name: ' 归属母公司', key: 'parent_comprehensive_income', path: 'comprehensive_income.parent_comprehensive_income' },
{ name: ' 归属少数股东', key: 'minority_comprehensive_income', path: 'comprehensive_income.minority_comprehensive_income' },
]
}
];
// 数组安全检查
if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无利润表数据
</Alert>
);
}
const maxColumns = Math.min(incomeStatement.length, 6);
const displayData = incomeStatement.slice(0, maxColumns);
const renderSection = (section) => (
<React.Fragment key={section.key}>
<Tr
bg={useColorModeValue('gray.50', 'gray.700')}
cursor="pointer"
onClick={() => toggleSection(section.key)}
>
<Td colSpan={maxColumns + 2}>
<HStack>
{expandedSections[section.key] ? <ChevronUpIcon /> : <ChevronDownIcon />}
<Text fontWeight="bold">{section.title}</Text>
</HStack>
</Td>
</Tr>
{expandedSections[section.key] && section.metrics.map(metric => {
const rowData = incomeStatement.map(item => {
const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item);
return value;
});
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, incomeStatement, metric.path)}
bg={metric.isTotal ? useColorModeValue('blue.50', 'blue.900') :
metric.isSubtotal ? useColorModeValue('orange.50', 'orange.900') : 'transparent'}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
{!metric.isTotal && !metric.isSubtotal && <Box w={metric.name.startsWith(' ') ? 8 : 4} />}
<Text
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'medium'}
fontSize={metric.isTotal ? 'sm' : 'xs'}
>
{metric.name}
</Text>
{metric.isCore && <Badge size="xs" colorScheme="purple">核心</Badge>}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value,
item.period,
incomeStatement,
metric.path
);
// 特殊处理:成本费用类负向指标,增长用绿色,减少用红色
const isCostItem = metric.key.includes('cost') || metric.key.includes('expense') ||
metric.key === 'income_tax' || metric.key.includes('impairment');
const displayColor = isCostItem ?
(change > 0 ? negativeColor : positiveColor) :
(change > 0 ? positiveColor : negativeColor);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>
数值: {metric.key.includes('eps') ?
value?.toFixed(3) :
formatUtils.formatLargeNumber(value)}
</Text>
<Text>同比: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
fontWeight={metric.isTotal || metric.isSubtotal ? 'bold' : 'normal'}
color={value < 0 ? 'red.500' : 'inherit'}
>
{metric.key.includes('eps') ?
value?.toFixed(3) :
formatUtils.formatLargeNumber(value, 0)}
</Text>
</Tooltip>
{Math.abs(change) > 30 && !metric.isTotal && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
{Math.abs(change).toFixed(0)}%
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
/>
</Td>
</Tr>
);
})}
</React.Fragment>
);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="250px">项目</Th>
{displayData.map(item => (
<Th key={item.period} isNumeric fontSize="xs" minW="120px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px">操作</Th>
</Tr>
</Thead>
<Tbody>
{sections.map(section => renderSection(section))}
</Tbody>
</Table>
</TableContainer>
);
};
// 现金流量表组件
const CashflowTable = () => {
const metrics = [
{ name: '经营现金流净额', key: 'operating_net', path: 'operating_activities.net_flow' },
{ name: '销售收现', key: 'cash_from_sales', path: 'operating_activities.inflow.cash_from_sales' },
{ name: '购买支付现金', key: 'cash_for_goods', path: 'operating_activities.outflow.cash_for_goods' },
{ name: '投资现金流净额', key: 'investment_net', path: 'investment_activities.net_flow' },
{ name: '筹资现金流净额', key: 'financing_net', path: 'financing_activities.net_flow' },
{ name: '现金净增加额', key: 'net_increase', path: 'cash_changes.net_increase' },
{ name: '期末现金余额', key: 'ending_balance', path: 'cash_changes.ending_balance' },
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
];
// 数组安全检查
if (!Array.isArray(cashflow) || cashflow.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无现金流量表数据
</Alert>
);
}
const maxColumns = Math.min(cashflow.length, 8);
const displayData = cashflow.slice(0, maxColumns);
return (
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1}>项目</Th>
{displayData.map(item => (
<Th key={item.period} isNumeric fontSize="xs">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th>趋势</Th>
</Tr>
</Thead>
<Tbody>
{metrics.map(metric => {
const rowData = cashflow.map(item => {
const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item);
return value;
});
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, cashflow, metric.path)}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack>
<Text fontWeight="medium">{metric.name}</Text>
{['operating_net', 'free_cash_flow'].includes(metric.key) &&
<Badge colorScheme="purple">核心</Badge>}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const isNegative = value < 0;
const { change, intensity } = calculateYoYChange(
value,
item.period,
cashflow,
metric.path
);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>数值: {formatUtils.formatLargeNumber(value)}</Text>
<Text>同比: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={isNegative ? negativeColor : positiveColor}
>
{formatUtils.formatLargeNumber(value, 1)}
</Text>
</Tooltip>
{Math.abs(change) > 50 && (
<Text
position="absolute"
top="0"
right="1"
fontSize="2xs"
color={change > 0 ? positiveColor : negativeColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
);
};
// 财务指标表格组件 - 时间序列版
const FinancialMetricsTable = () => {
const [selectedCategory, setSelectedCategory] = useState('profitability');
const metricsCategories = {
profitability: {
title: '盈利能力指标',
metrics: [
{ name: '净资产收益率(ROE)%', key: 'roe', path: 'profitability.roe', isCore: true },
{ name: '净资产收益率(扣非)%', key: 'roe_deducted', path: 'profitability.roe_deducted' },
{ name: '净资产收益率(加权)%', key: 'roe_weighted', path: 'profitability.roe_weighted', isCore: true },
{ name: '总资产报酬率(ROA)%', key: 'roa', path: 'profitability.roa', isCore: true },
{ name: '毛利率%', key: 'gross_margin', path: 'profitability.gross_margin', isCore: true },
{ name: '净利率%', key: 'net_margin', path: 'profitability.net_profit_margin', isCore: true },
{ name: '营业利润率%', key: 'operating_margin', path: 'profitability.operating_profit_margin' },
{ name: '成本费用利润率%', key: 'cost_profit_ratio', path: 'profitability.cost_profit_ratio' },
{ name: 'EBIT', key: 'ebit', path: 'profitability.ebit' },
]
},
perShare: {
title: '每股指标',
metrics: [
{ name: '每股收益(EPS)', key: 'eps', path: 'per_share_metrics.eps', isCore: true },
{ name: '基本每股收益', key: 'basic_eps', path: 'per_share_metrics.basic_eps', isCore: true },
{ name: '稀释每股收益', key: 'diluted_eps', path: 'per_share_metrics.diluted_eps' },
{ name: '扣非每股收益', key: 'deducted_eps', path: 'per_share_metrics.deducted_eps', isCore: true },
{ name: '每股净资产', key: 'bvps', path: 'per_share_metrics.bvps', isCore: true },
{ name: '每股经营现金流', key: 'operating_cash_flow_ps', path: 'per_share_metrics.operating_cash_flow_ps' },
{ name: '每股资本公积', key: 'capital_reserve_ps', path: 'per_share_metrics.capital_reserve_ps' },
{ name: '每股未分配利润', key: 'undistributed_profit_ps', path: 'per_share_metrics.undistributed_profit_ps' },
]
},
growth: {
title: '成长能力指标',
metrics: [
{ name: '营收增长率%', key: 'revenue_growth', path: 'growth.revenue_growth', isCore: true },
{ name: '净利润增长率%', key: 'profit_growth', path: 'growth.net_profit_growth', isCore: true },
{ name: '扣非净利润增长率%', key: 'deducted_profit_growth', path: 'growth.deducted_profit_growth', isCore: true },
{ name: '归母净利润增长率%', key: 'parent_profit_growth', path: 'growth.parent_profit_growth' },
{ name: '经营现金流增长率%', key: 'operating_cash_flow_growth', path: 'growth.operating_cash_flow_growth' },
{ name: '总资产增长率%', key: 'asset_growth', path: 'growth.total_asset_growth' },
{ name: '净资产增长率%', key: 'equity_growth', path: 'growth.equity_growth' },
{ name: '固定资产增长率%', key: 'fixed_asset_growth', path: 'growth.fixed_asset_growth' },
]
},
operational: {
title: '运营效率指标',
metrics: [
{ name: '总资产周转率', key: 'asset_turnover', path: 'operational_efficiency.total_asset_turnover', isCore: true },
{ name: '固定资产周转率', key: 'fixed_asset_turnover', path: 'operational_efficiency.fixed_asset_turnover' },
{ name: '流动资产周转率', key: 'current_asset_turnover', path: 'operational_efficiency.current_asset_turnover' },
{ name: '应收账款周转率', key: 'receivable_turnover', path: 'operational_efficiency.receivable_turnover', isCore: true },
{ name: '应收账款周转天数', key: 'receivable_days', path: 'operational_efficiency.receivable_days', isCore: true },
{ name: '存货周转率', key: 'inventory_turnover', path: 'operational_efficiency.inventory_turnover', isCore: true },
{ name: '存货周转天数', key: 'inventory_days', path: 'operational_efficiency.inventory_days' },
{ name: '营运资金周转率', key: 'working_capital_turnover', path: 'operational_efficiency.working_capital_turnover' },
]
},
solvency: {
title: '偿债能力指标',
metrics: [
{ name: '流动比率', key: 'current_ratio', path: 'solvency.current_ratio', isCore: true },
{ name: '速动比率', key: 'quick_ratio', path: 'solvency.quick_ratio', isCore: true },
{ name: '现金比率', key: 'cash_ratio', path: 'solvency.cash_ratio' },
{ name: '保守速动比率', key: 'conservative_quick_ratio', path: 'solvency.conservative_quick_ratio' },
{ name: '资产负债率%', key: 'debt_ratio', path: 'solvency.asset_liability_ratio', isCore: true },
{ name: '利息保障倍数', key: 'interest_coverage', path: 'solvency.interest_coverage' },
{ name: '现金到期债务比', key: 'cash_to_maturity_debt', path: 'solvency.cash_to_maturity_debt_ratio' },
{ name: '有形资产净值债务率%', key: 'tangible_asset_debt_ratio', path: 'solvency.tangible_asset_debt_ratio' },
]
},
expense: {
title: '费用率指标',
metrics: [
{ name: '销售费用率%', key: 'selling_expense_ratio', path: 'expense_ratios.selling_expense_ratio', isCore: true },
{ name: '管理费用率%', key: 'admin_expense_ratio', path: 'expense_ratios.admin_expense_ratio', isCore: true },
{ name: '财务费用率%', key: 'financial_expense_ratio', path: 'expense_ratios.financial_expense_ratio' },
{ name: '研发费用率%', key: 'rd_expense_ratio', path: 'expense_ratios.rd_expense_ratio', isCore: true },
{ name: '三费费用率%', key: 'three_expense_ratio', path: 'expense_ratios.three_expense_ratio' },
{ name: '四费费用率%', key: 'four_expense_ratio', path: 'expense_ratios.four_expense_ratio' },
{ name: '营业成本率%', key: 'cost_ratio', path: 'expense_ratios.cost_ratio' },
]
},
cashflow: {
title: '现金流量指标',
metrics: [
{ name: '经营现金流/净利润', key: 'cash_to_profit', path: 'cash_flow_quality.operating_cash_to_profit_ratio', isCore: true },
{ name: '净利含金量', key: 'profit_cash_content', path: 'cash_flow_quality.cash_to_profit_ratio', isCore: true },
{ name: '营收现金含量', key: 'revenue_cash_content', path: 'cash_flow_quality.cash_revenue_ratio' },
{ name: '全部资产现金回收率%', key: 'cash_recovery_rate', path: 'cash_flow_quality.cash_recovery_rate' },
{ name: '经营现金流/短期债务', key: 'cash_to_short_debt', path: 'cash_flow_quality.operating_cash_to_short_debt' },
{ name: '经营现金流/总债务', key: 'cash_to_total_debt', path: 'cash_flow_quality.operating_cash_to_total_debt' },
]
}
};
// 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无财务指标数据
</Alert>
);
}
const maxColumns = Math.min(financialMetrics.length, 6);
const displayData = financialMetrics.slice(0, maxColumns);
const currentCategory = metricsCategories[selectedCategory];
return (
<VStack spacing={4} align="stretch">
{/* 分类选择器 */}
<HStack spacing={2} wrap="wrap">
{Object.entries(metricsCategories).map(([key, category]) => (
<Button
key={key}
size="sm"
variant={selectedCategory === key ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => setSelectedCategory(key)}
>
{category.title}
</Button>
))}
</HStack>
{/* 指标表格 */}
<TableContainer>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th position="sticky" left={0} bg={bgColor} zIndex={1} minW="200px">
{currentCategory.title}
</Th>
{displayData.map(item => (
<Th key={item.period} isNumeric fontSize="xs" minW="100px">
<VStack spacing={0}>
<Text>{formatUtils.getReportType(item.period)}</Text>
<Text fontSize="2xs" color="gray.500">
{item.period.substring(0, 10)}
</Text>
</VStack>
</Th>
))}
<Th w="50px">趋势</Th>
</Tr>
</Thead>
<Tbody>
{currentCategory.metrics.map(metric => {
const rowData = financialMetrics.map(item => {
const value = metric.path.split('.').reduce((obj, key) => obj?.[key], item);
return value;
});
return (
<Tr
key={metric.key}
_hover={{ bg: hoverBg, cursor: 'pointer' }}
onClick={() => showMetricChart(metric.name, metric.key, financialMetrics, metric.path)}
>
<Td position="sticky" left={0} bg={bgColor} zIndex={1}>
<HStack spacing={2}>
<Text fontWeight="medium" fontSize="xs">
{metric.name}
</Text>
{metric.isCore && <Badge size="xs" colorScheme="purple">核心</Badge>}
</HStack>
</Td>
{displayData.map((item, idx) => {
const value = rowData[idx];
const { change, intensity } = calculateYoYChange(
value,
item.period,
financialMetrics,
metric.path
);
// 判断指标性质
const isNegativeIndicator = metric.key.includes('days') ||
metric.key.includes('expense_ratio') ||
metric.key.includes('debt_ratio') ||
metric.key.includes('cost_ratio');
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
const displayColor = isNegativeIndicator ?
(change > 0 ? negativeColor : positiveColor) :
(change > 0 ? positiveColor : negativeColor);
return (
<Td
key={idx}
isNumeric
bg={getCellBackground(change, intensity * 0.3)}
position="relative"
>
<Tooltip
label={
<VStack align="start" spacing={0}>
<Text>{metric.name}: {value?.toFixed(2) || '-'}</Text>
<Text>同比: {change.toFixed(2)}%</Text>
</VStack>
}
placement="top"
>
<Text
fontSize="xs"
color={
selectedCategory === 'growth' ?
(value > 0 ? positiveColor : value < 0 ? negativeColor : 'gray.500') :
'inherit'
}
>
{value?.toFixed(2) || '-'}
</Text>
</Tooltip>
{Math.abs(change) > 20 && Math.abs(value) > 0.01 && (
<Text
position="absolute"
top="-1"
right="0"
fontSize="2xs"
color={displayColor}
fontWeight="bold"
>
{change > 0 ? '↑' : '↓'}
</Text>
)}
</Td>
);
})}
<Td>
<IconButton
size="xs"
icon={<ViewIcon />}
variant="ghost"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
showMetricChart(metric.name, metric.key, financialMetrics, metric.path);
}}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
{/* 关键指标快速对比 */}
<Card>
<CardHeader>
<Heading size="sm">关键指标速览</Heading>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={4}>
{financialMetrics[0] && [
{ label: 'ROE', value: financialMetrics[0].profitability?.roe, format: 'percent' },
{ label: '毛利率', value: financialMetrics[0].profitability?.gross_margin, format: 'percent' },
{ label: '净利率', value: financialMetrics[0].profitability?.net_profit_margin, format: 'percent' },
{ label: '流动比率', value: financialMetrics[0].solvency?.current_ratio, format: 'decimal' },
{ label: '资产负债率', value: financialMetrics[0].solvency?.asset_liability_ratio, format: 'percent' },
{ label: '研发费用率', value: financialMetrics[0].expense_ratios?.rd_expense_ratio, format: 'percent' },
].map((item, idx) => (
<Box key={idx} p={3} borderRadius="md" bg={useColorModeValue('gray.50', 'gray.700')}>
<Text fontSize="xs" color="gray.500">{item.label}</Text>
<Text fontSize="lg" fontWeight="bold">
{item.format === 'percent' ?
formatUtils.formatPercent(item.value) :
item.value?.toFixed(2) || '-'}
</Text>
</Box>
))}
</SimpleGrid>
</CardBody>
</Card>
</VStack>
);
};
// 主营业务分析组件 - 修复bug支持product和industry两种分类
const MainBusinessAnalysis = () => {
// 优先使用product_classification如果为空则使用industry_classification
const hasProductData = mainBusiness?.product_classification?.length > 0;
const hasIndustryData = mainBusiness?.industry_classification?.length > 0;
if (!hasProductData && !hasIndustryData) {
return (
<Alert status="info">
<AlertIcon />
暂无主营业务数据
</Alert>
);
}
// 选择数据源
const dataSource = hasProductData ? 'product' : 'industry';
const latestPeriod = hasProductData ?
mainBusiness.product_classification[0] :
mainBusiness.industry_classification[0];
const businessItems = hasProductData ? latestPeriod.products : latestPeriod.industries;
// 过滤掉"合计"项,准备饼图数据
const pieData = businessItems
.filter(item => item.content !== '合计')
.map(item => ({
name: item.content,
value: item.revenue || 0
}));
const pieOption = {
title: {
text: `主营业务构成 - ${latestPeriod.report_type}`,
subtext: dataSource === 'industry' ? '按行业分类' : '按产品分类',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}<br/>营收: ${formatUtils.formatLargeNumber(params.value)}<br/>占比: ${params.percent}%`;
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'center'
},
series: [{
type: 'pie',
radius: '50%',
data: pieData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
};
// 历史对比数据
const historicalData = hasProductData ?
mainBusiness.product_classification :
mainBusiness.industry_classification;
return (
<VStack spacing={4} align="stretch">
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<GridItem>
<Card>
<CardBody>
<ReactECharts option={pieOption} style={{ height: '300px' }} />
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm">业务明细 - {latestPeriod.report_type}</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>业务</Th>
<Th isNumeric>营收</Th>
<Th isNumeric>毛利率(%)</Th>
<Th isNumeric>利润</Th>
</Tr>
</Thead>
<Tbody>
{businessItems
.filter(item => item.content !== '合计')
.map((item, idx) => (
<Tr key={idx}>
<Td>{item.content}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.revenue)}</Td>
<Td isNumeric>{formatUtils.formatPercent(item.gross_margin || item.profit_margin)}</Td>
<Td isNumeric>{formatUtils.formatLargeNumber(item.profit)}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
</GridItem>
</Grid>
{/* 历史对比 */}
{historicalData.length > 1 && (
<Card>
<CardHeader>
<Heading size="sm">主营业务历史对比</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>业务/期间</Th>
{historicalData.slice(0, 3).map(period => (
<Th key={period.period} isNumeric>{period.report_type}</Th>
))}
</Tr>
</Thead>
<Tbody>
{businessItems
.filter(item => item.content !== '合计')
.map((item, idx) => (
<Tr key={idx}>
<Td>{item.content}</Td>
{historicalData.slice(0, 3).map(period => {
const periodItems = hasProductData ? period.products : period.industries;
const matchItem = periodItems.find(p => p.content === item.content);
return (
<Td key={period.period} isNumeric>
{matchItem ? formatUtils.formatLargeNumber(matchItem.revenue) : '-'}
</Td>
);
})}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
)}
</VStack>
);
};
// 行业排名组件
const IndustryRankingView = () => {
if (!industryRank || industryRank.length === 0) {
return (
<Alert status="info">
<AlertIcon />
暂无行业排名数据
</Alert>
);
}
const latestRanking = industryRank[0];
const rankingMetrics = [
{ name: 'EPS', key: 'eps' },
{ name: '每股净资产', key: 'bvps' },
{ name: 'ROE', key: 'roe' },
{ name: '营收增长率', key: 'revenue_growth' },
{ name: '利润增长率', key: 'profit_growth' },
{ name: '营业利润率', key: 'operating_margin' },
{ name: '资产负债率', key: 'debt_ratio' },
{ name: '应收账款周转率', key: 'receivable_turnover' }
];
return (
<VStack spacing={4} align="stretch">
{Array.isArray(industryRank) && industryRank.length > 0 ? (
industryRank.map((periodData, periodIdx) => (
<Card key={periodIdx}>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
<Badge colorScheme="purple">{periodData.period}</Badge>
</HStack>
</CardHeader>
<CardBody>
{periodData.rankings?.map((ranking, idx) => (
<Box key={idx} mb={6}>
<Text fontWeight="bold" mb={3}>
{ranking.industry_name} ({ranking.level_description})
</Text>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={3}>
{rankingMetrics.map(metric => {
const metricData = ranking.metrics?.[metric.key];
if (!metricData) return null;
const isGood = metricData.rank && metricData.rank <= 10;
const isBad = metricData.rank && metricData.rank > 30;
return (
<Box
key={metric.key}
p={3}
borderRadius="md"
bg={bgColor}
borderWidth="1px"
borderColor={borderColor}
>
<Text fontSize="xs" color="gray.500">{metric.name}</Text>
<HStack mt={1}>
<Text fontWeight="bold">
{metric.key.includes('growth') || metric.key.includes('margin') || metric.key === 'roe' ?
formatUtils.formatPercent(metricData.value) :
metricData.value?.toFixed(2) || '-'}
</Text>
{metricData.rank && (
<Badge
size="sm"
colorScheme={isGood ? 'red' : isBad ? 'green' : 'gray'}
>
#{metricData.rank}
</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.500" mt={1}>
行业均值: {metric.key.includes('growth') || metric.key.includes('margin') || metric.key === 'roe' ?
formatUtils.formatPercent(metricData.industry_avg) :
metricData.industry_avg?.toFixed(2) || '-'}
</Text>
</Box>
);
})}
</SimpleGrid>
</Box>
))}
</CardBody>
</Card>
))
) : (
<Card>
<CardBody>
<Text textAlign="center" color="gray.500" py={8}>
暂无行业排名数据
</Text>
</CardBody>
</Card>
)}
</VStack>
);
};
// 股票对比组件
const StockComparison = ({ currentStock }) => {
const [compareStock, setCompareStock] = useState('');
const [compareData, setCompareData] = useState(null);
const [compareLoading, setCompareLoading] = useState(false);
const loadCompareData = async () => {
if (!compareStock || compareStock.length !== 6) {
logger.warn('FinancialPanorama', 'loadCompareData', '无效的对比股票代码', { compareStock });
toast({
title: '请输入有效的6位股票代码',
status: 'warning',
duration: 3000,
});
return;
}
logger.debug('FinancialPanorama', '开始加载对比数据', { currentStock, compareStock });
setCompareLoading(true);
try {
const [stockInfoRes, metricsRes, comparisonRes] = await Promise.all([
financialService.getStockInfo(compareStock),
financialService.getFinancialMetrics(compareStock, 4),
financialService.getPeriodComparison(compareStock, 4)
]);
setCompareData({
stockInfo: stockInfoRes.data,
metrics: metricsRes.data,
comparison: comparisonRes.data
});
// ❌ 移除对比数据加载成功toast
logger.info('FinancialPanorama', '对比数据加载成功', { currentStock, compareStock });
} catch (error) {
logger.error('FinancialPanorama', 'loadCompareData', error, { currentStock, compareStock });
// ❌ 移除对比数据加载失败toast
// toast({ title: '加载对比数据失败', description: error.message, status: 'error', duration: 3000 });
} finally {
setCompareLoading(false);
}
};
const compareMetrics = [
{ label: '营业收入', key: 'revenue', path: 'financial_summary.revenue' },
{ label: '净利润', key: 'net_profit', path: 'financial_summary.net_profit' },
{ label: 'ROE', key: 'roe', path: 'key_metrics.roe', format: 'percent' },
{ label: 'ROA', key: 'roa', path: 'key_metrics.roa', format: 'percent' },
{ label: '毛利率', key: 'gross_margin', path: 'key_metrics.gross_margin', format: 'percent' },
{ label: '净利率', key: 'net_margin', path: 'key_metrics.net_margin', format: 'percent' },
{ label: '营收增长率', key: 'revenue_growth', path: 'growth_rates.revenue_growth', format: 'percent' },
{ label: '利润增长率', key: 'profit_growth', path: 'growth_rates.profit_growth', format: 'percent' },
{ label: '资产总额', key: 'total_assets', path: 'financial_summary.total_assets' },
{ label: '负债总额', key: 'total_liabilities', path: 'financial_summary.total_liabilities' },
];
return (
<VStack spacing={4} align="stretch">
<Card>
<CardBody>
<HStack>
<Input
placeholder="输入对比股票代码"
value={compareStock}
onChange={(e) => setCompareStock(e.target.value)}
maxLength={6}
/>
<Button
colorScheme="blue"
onClick={loadCompareData}
isLoading={compareLoading}
>
添加对比
</Button>
</HStack>
</CardBody>
</Card>
{compareData && (
<Card>
<CardHeader>
<Heading size="md">
{stockInfo?.stock_name} ({currentStock}) VS {compareData.stockInfo?.stock_name} ({compareStock})
</Heading>
</CardHeader>
<CardBody>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>指标</Th>
<Th isNumeric>{stockInfo?.stock_name}</Th>
<Th isNumeric>{compareData.stockInfo?.stock_name}</Th>
<Th isNumeric>差异</Th>
</Tr>
</Thead>
<Tbody>
{compareMetrics.map(metric => {
const value1 = metric.path.split('.').reduce((obj, key) => obj?.[key], stockInfo);
const value2 = metric.path.split('.').reduce((obj, key) => obj?.[key], compareData.stockInfo);
let diff = null;
let diffColor = 'gray.500';
if (value1 && value2) {
if (metric.format === 'percent') {
diff = value1 - value2;
diffColor = diff > 0 ? positiveColor : negativeColor;
} else {
diff = ((value1 - value2) / value2) * 100;
diffColor = diff > 0 ? positiveColor : negativeColor;
}
}
return (
<Tr key={metric.key}>
<Td>{metric.label}</Td>
<Td isNumeric>
{metric.format === 'percent' ?
formatUtils.formatPercent(value1) :
formatUtils.formatLargeNumber(value1)}
</Td>
<Td isNumeric>
{metric.format === 'percent' ?
formatUtils.formatPercent(value2) :
formatUtils.formatLargeNumber(value2)}
</Td>
<Td isNumeric color={diffColor}>
{diff !== null ? (
<HStack spacing={1}>
{diff > 0 && <ArrowUpIcon boxSize={3} />}
{diff < 0 && <ArrowDownIcon boxSize={3} />}
<Text>
{metric.format === 'percent' ?
`${Math.abs(diff).toFixed(2)}pp` :
`${Math.abs(diff).toFixed(2)}%`}
</Text>
</HStack>
) : '-'}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
{/* 对比图表 */}
<Grid templateColumns="repeat(2, 1fr)" gap={4} mt={6}>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm">盈利能力对比</Heading>
</CardHeader>
<CardBody>
<ReactECharts
option={{
tooltip: { trigger: 'axis' },
legend: { data: [stockInfo?.stock_name, compareData.stockInfo?.stock_name] },
xAxis: {
type: 'category',
data: ['ROE', 'ROA', '毛利率', '净利率']
},
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [
{
name: stockInfo?.stock_name,
type: 'bar',
data: [
stockInfo?.key_metrics?.roe,
stockInfo?.key_metrics?.roa,
stockInfo?.key_metrics?.gross_margin,
stockInfo?.key_metrics?.net_margin
]
},
{
name: compareData.stockInfo?.stock_name,
type: 'bar',
data: [
compareData.stockInfo?.key_metrics?.roe,
compareData.stockInfo?.key_metrics?.roa,
compareData.stockInfo?.key_metrics?.gross_margin,
compareData.stockInfo?.key_metrics?.net_margin
]
}
]
}}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card>
<CardHeader>
<Heading size="sm">成长能力对比</Heading>
</CardHeader>
<CardBody>
<ReactECharts
option={{
tooltip: { trigger: 'axis' },
legend: { data: [stockInfo?.stock_name, compareData.stockInfo?.stock_name] },
xAxis: {
type: 'category',
data: ['营收增长', '利润增长', '资产增长', '股东权益增长']
},
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
series: [
{
name: stockInfo?.stock_name,
type: 'bar',
data: [
stockInfo?.growth_rates?.revenue_growth,
stockInfo?.growth_rates?.profit_growth,
stockInfo?.growth_rates?.asset_growth,
stockInfo?.growth_rates?.equity_growth
]
},
{
name: compareData.stockInfo?.stock_name,
type: 'bar',
data: [
compareData.stockInfo?.growth_rates?.revenue_growth,
compareData.stockInfo?.growth_rates?.profit_growth,
compareData.stockInfo?.growth_rates?.asset_growth,
compareData.stockInfo?.growth_rates?.equity_growth
]
}
]
}}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
</Grid>
</CardBody>
</Card>
)}
</VStack>
);
};
// 综合对比分析
const ComparisonAnalysis = () => {
if (!Array.isArray(comparison) || comparison.length === 0) return null;
const revenueData = comparison.map(item => ({
period: formatUtils.getReportType(item.period),
value: item.performance.revenue / 100000000 // 转换为亿
})).reverse();
const profitData = comparison.map(item => ({
period: formatUtils.getReportType(item.period),
value: item.performance.net_profit / 100000000 // 转换为亿
})).reverse();
const combinedOption = {
title: {
text: '营收与利润趋势',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['营业收入', '净利润'],
bottom: 0
},
xAxis: {
type: 'category',
data: revenueData.map(d => d.period)
},
yAxis: [
{
type: 'value',
name: '营收(亿)',
position: 'left'
},
{
type: 'value',
name: '利润(亿)',
position: 'right'
}
],
series: [
{
name: '营业收入',
type: 'bar',
data: revenueData.map(d => d.value?.toFixed(2)),
itemStyle: {
color: (params) => {
const idx = params.dataIndex;
if (idx === 0) return '#3182CE';
const prevValue = revenueData[idx - 1].value;
const currValue = params.value;
// 中国市场颜色
return currValue >= prevValue ? '#EF4444' : '#10B981';
}
}
},
{
name: '净利润',
type: 'line',
yAxisIndex: 1,
data: profitData.map(d => d.value?.toFixed(2)),
smooth: true,
itemStyle: { color: '#F59E0B' },
lineStyle: { width: 2 }
}
]
};
return (
<Card>
<CardBody>
<ReactECharts option={combinedOption} style={{ height: '400px' }} />
</CardBody>
</Card>
);
};
// 页面头部信息
const StockInfoHeader = () => {
if (!stockInfo) return null;
return (
<Card mb={4}>
<CardBody>
<Grid templateColumns="repeat(6, 1fr)" gap={4}>
<GridItem colSpan={{ base: 6, md: 2 }}>
<VStack align="start">
<Text fontSize="xs" color="gray.500">股票名称</Text>
<HStack>
<Heading size="md">{stockInfo.stock_name}</Heading>
<Badge>{stockInfo.stock_code}</Badge>
</HStack>
</VStack>
</GridItem>
<GridItem>
<Stat>
<StatLabel>最新EPS</StatLabel>
<StatNumber>{stockInfo.key_metrics?.eps?.toFixed(3) || '-'}</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel>ROE</StatLabel>
<StatNumber>{formatUtils.formatPercent(stockInfo.key_metrics?.roe)}</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel>营收增长</StatLabel>
<StatNumber color={stockInfo.growth_rates?.revenue_growth > 0 ? positiveColor : stockInfo.growth_rates?.revenue_growth < 0 ? negativeColor : 'gray.500'}>
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
</StatNumber>
</Stat>
</GridItem>
<GridItem>
<Stat>
<StatLabel>利润增长</StatLabel>
<StatNumber color={stockInfo.growth_rates?.profit_growth > 0 ? positiveColor : stockInfo.growth_rates?.profit_growth < 0 ? negativeColor : 'gray.500'}>
{formatUtils.formatPercent(stockInfo.growth_rates?.profit_growth)}
</StatNumber>
</Stat>
</GridItem>
</Grid>
{stockInfo.latest_forecast && (
<Alert status="info" mt={4}>
<AlertIcon />
<Box>
<Text fontWeight="bold">{stockInfo.latest_forecast.forecast_type}</Text>
<Text fontSize="sm">{stockInfo.latest_forecast.content}</Text>
</Box>
</Alert>
)}
</CardBody>
</Card>
);
};
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 时间选择器 */}
<Card>
<CardBody>
<HStack justify="space-between">
<HStack>
<Text fontSize="sm" color="gray.600">显示期数</Text>
<Select
value={selectedPeriods}
onChange={(e) => setSelectedPeriods(Number(e.target.value))}
w="150px"
size="sm"
>
<option value={4}>最近4期</option>
<option value={8}>最近8期</option>
<option value={12}>最近12期</option>
<option value={16}>最近16期</option>
</Select>
</HStack>
<IconButton
icon={<RepeatIcon />}
onClick={loadFinancialData}
isLoading={loading}
variant="outline"
size="sm"
aria-label="刷新数据"
/>
</HStack>
</CardBody>
</Card>
{/* 股票信息头部 */}
{loading ? (
<Skeleton height="150px" />
) : (
<StockInfoHeader />
)}
{/* 主要内容区域 */}
{!loading && stockInfo && (
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="enclosed"
colorScheme="blue"
>
<TabList>
<Tab>财务概览</Tab>
<Tab>资产负债表</Tab>
<Tab>利润表</Tab>
<Tab>现金流量表</Tab>
<Tab>财务指标</Tab>
<Tab>主营业务</Tab>
<Tab>行业排名</Tab>
<Tab>业绩预告</Tab>
<Tab>股票对比</Tab>
</TabList>
<TabPanels>
{/* 财务概览 */}
<TabPanel>
<VStack spacing={4} align="stretch">
<ComparisonAnalysis />
<FinancialMetricsTable />
</VStack>
</TabPanel>
{/* 资产负债表 */}
<TabPanel>
<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>
</HStack>
<Text fontSize="xs" color="gray.500">
提示表格可横向滚动查看更多数据点击行查看历史趋势
</Text>
</VStack>
</CardHeader>
<CardBody>
<BalanceSheetTable />
</CardBody>
</Card>
</TabPanel>
{/* 利润表 */}
<TabPanel>
<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>
</HStack>
<Text fontSize="xs" color="gray.500">
提示Q1中报Q3年报数据为累计值同比显示与去年同期对比
</Text>
</VStack>
</CardHeader>
<CardBody>
<IncomeStatementTable />
</CardBody>
</Card>
</TabPanel>
{/* 现金流量表 */}
<TabPanel>
<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>
</HStack>
<Text fontSize="xs" color="gray.500">
提示现金流数据为累计值正值红色表示现金流入负值绿色表示现金流出
</Text>
</VStack>
</CardHeader>
<CardBody>
<CashflowTable />
</CardBody>
</Card>
</TabPanel>
{/* 财务指标 */}
<TabPanel>
<FinancialMetricsTable />
</TabPanel>
{/* 主营业务 */}
<TabPanel>
<MainBusinessAnalysis />
</TabPanel>
{/* 行业排名 */}
<TabPanel>
<IndustryRankingView />
</TabPanel>
{/* 业绩预告 */}
<TabPanel>
{forecast && (
<VStack spacing={4} align="stretch">
{forecast.forecasts?.map((item, idx) => (
<Card key={idx}>
<CardBody>
<HStack justify="space-between" mb={2}>
<Badge colorScheme="blue">{item.forecast_type}</Badge>
<Text fontSize="sm" color="gray.500">
报告期: {item.report_date}
</Text>
</HStack>
<Text mb={2}>{item.content}</Text>
{item.reason && (
<Text fontSize="sm" color="gray.600">{item.reason}</Text>
)}
{item.change_range?.lower && (
<HStack mt={2}>
<Text fontSize="sm">预计变动范围:</Text>
<Badge colorScheme="green">
{item.change_range.lower}% ~ {item.change_range.upper}%
</Badge>
</HStack>
)}
</CardBody>
</Card>
))}
</VStack>
)}
</TabPanel>
{/* 股票对比 */}
<TabPanel>
<StockComparison currentStock={stockCode} />
</TabPanel>
</TabPanels>
</Tabs>
)}
{/* 错误提示 */}
{error && (
<Alert status="error">
<AlertIcon />
{error}
</Alert>
)}
{/* 弹出模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent maxW="900px">
<ModalHeader>指标详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalContent}
</ModalBody>
</ModalContent>
</Modal>
</VStack>
</Container>
);
};
export default FinancialPanorama;