2154 lines
87 KiB
JavaScript
2154 lines
87 KiB
JavaScript
// 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]);
|
||
|
||
// 初始加载
|
||
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; |