refactor(FinancialPanorama): 添加9个子组件
财务报表组件: - BalanceSheetTable: 资产负债表(可折叠分类) - IncomeStatementTable: 利润表(支持负向指标反色) - CashflowTable: 现金流量表 - FinancialMetricsTable: 财务指标(7分类切换 + 关键指标速览) 业务分析组件: - StockInfoHeader: 股票信息头部 - MainBusinessAnalysis: 主营业务分析(饼图 + 表格) - IndustryRankingView: 行业排名展示 - StockComparison: 股票对比(多维度) - ComparisonAnalysis: 综合对比分析(双轴图) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* 资产负债表组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Box,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import {
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
EQUITY_METRICS,
|
||||||
|
} from '../constants';
|
||||||
|
import { getValueByPath } from '../utils';
|
||||||
|
import type { BalanceSheetTableProps, MetricSectionConfig } from '../types';
|
||||||
|
|
||||||
|
export const BalanceSheetTable: React.FC<BalanceSheetTableProps> = ({
|
||||||
|
data,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
currentAssets: true,
|
||||||
|
nonCurrentAssets: true,
|
||||||
|
currentLiabilities: true,
|
||||||
|
nonCurrentLiabilities: true,
|
||||||
|
equity: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 资产部分配置
|
||||||
|
const assetSections: MetricSectionConfig[] = [
|
||||||
|
CURRENT_ASSETS_METRICS,
|
||||||
|
NON_CURRENT_ASSETS_METRICS,
|
||||||
|
TOTAL_ASSETS_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 负债部分配置
|
||||||
|
const liabilitySections: MetricSectionConfig[] = [
|
||||||
|
CURRENT_LIABILITIES_METRICS,
|
||||||
|
NON_CURRENT_LIABILITIES_METRICS,
|
||||||
|
TOTAL_LIABILITIES_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 权益部分配置
|
||||||
|
const equitySections: MetricSectionConfig[] = [EQUITY_METRICS];
|
||||||
|
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
暂无资产负债表数据
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(data.length, 6);
|
||||||
|
const displayData = data.slice(0, maxColumns);
|
||||||
|
|
||||||
|
const renderSection = (sections: MetricSectionConfig[]) => (
|
||||||
|
<>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<React.Fragment key={section.key}>
|
||||||
|
{section.title !== '资产总计' &&
|
||||||
|
section.title !== '负债合计' && (
|
||||||
|
<Tr
|
||||||
|
bg="gray.50"
|
||||||
|
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 = data.map((item) =>
|
||||||
|
getValueByPath<number>(item, metric.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={metric.key}
|
||||||
|
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
showMetricChart(metric.name, metric.key, data, metric.path)
|
||||||
|
}
|
||||||
|
bg={metric.isTotal ? 'blue.50' : '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 ?? 0,
|
||||||
|
item.period,
|
||||||
|
data,
|
||||||
|
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"
|
||||||
|
aria-label="查看图表"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(metric.name, metric.key, data, 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)}
|
||||||
|
<Tr height={2} />
|
||||||
|
{renderSection(liabilitySections)}
|
||||||
|
<Tr height={2} />
|
||||||
|
{renderSection(equitySections)}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BalanceSheetTable;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 现金流量表组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ViewIcon } from '@chakra-ui/icons';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { CASHFLOW_METRICS } from '../constants';
|
||||||
|
import { getValueByPath } from '../utils';
|
||||||
|
import type { CashflowTableProps } from '../types';
|
||||||
|
|
||||||
|
export const CashflowTable: React.FC<CashflowTableProps> = ({
|
||||||
|
data,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
暂无现金流量表数据
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(data.length, 8);
|
||||||
|
const displayData = data.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>
|
||||||
|
{CASHFLOW_METRICS.map((metric) => {
|
||||||
|
const rowData = data.map((item) => getValueByPath<number>(item, metric.path));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={metric.key}
|
||||||
|
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||||
|
onClick={() => showMetricChart(metric.name, metric.key, data, 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 !== undefined && value < 0;
|
||||||
|
const { change, intensity } = calculateYoYChange(
|
||||||
|
value ?? 0,
|
||||||
|
item.period,
|
||||||
|
data,
|
||||||
|
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"
|
||||||
|
aria-label="查看趋势"
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashflowTable;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 综合对比分析组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardBody } from '@chakra-ui/react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { getComparisonChartOption } from '../utils';
|
||||||
|
import type { ComparisonAnalysisProps } from '../types';
|
||||||
|
|
||||||
|
export const ComparisonAnalysis: React.FC<ComparisonAnalysisProps> = ({ comparison }) => {
|
||||||
|
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 chartOption = getComparisonChartOption(revenueData, profitData);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<ReactECharts option={chartOption} style={{ height: '400px' }} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComparisonAnalysis;
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* 财务指标表格组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
SimpleGrid,
|
||||||
|
Box,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ViewIcon } from '@chakra-ui/icons';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { FINANCIAL_METRICS_CATEGORIES } from '../constants';
|
||||||
|
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { FinancialMetricsTableProps } from '../types';
|
||||||
|
|
||||||
|
type CategoryKey = keyof typeof FINANCIAL_METRICS_CATEGORIES;
|
||||||
|
|
||||||
|
export const FinancialMetricsTable: React.FC<FinancialMetricsTableProps> = ({
|
||||||
|
data,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('profitability');
|
||||||
|
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
暂无财务指标数据
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(data.length, 6);
|
||||||
|
const displayData = data.slice(0, maxColumns);
|
||||||
|
const currentCategory = FINANCIAL_METRICS_CATEGORIES[selectedCategory];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
{/* 分类选择器 */}
|
||||||
|
<HStack spacing={2} wrap="wrap">
|
||||||
|
{(Object.entries(FINANCIAL_METRICS_CATEGORIES) as [CategoryKey, typeof currentCategory][]).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 = data.map((item) =>
|
||||||
|
getValueByPath<number>(item, metric.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={metric.key}
|
||||||
|
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
showMetricChart(metric.name, metric.key, data, 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 ?? 0,
|
||||||
|
item.period,
|
||||||
|
data,
|
||||||
|
metric.path
|
||||||
|
);
|
||||||
|
|
||||||
|
// 判断指标性质
|
||||||
|
const isNegative = isNegativeIndicator(metric.key);
|
||||||
|
|
||||||
|
// 对于负向指标,增加是坏事(绿色),减少是好事(红色)
|
||||||
|
const displayColor = isNegative
|
||||||
|
? 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 !== undefined && value > 0
|
||||||
|
? positiveColor
|
||||||
|
: value !== undefined && value < 0
|
||||||
|
? negativeColor
|
||||||
|
: 'gray.500'
|
||||||
|
: 'inherit'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value?.toFixed(2) || '-'}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
{Math.abs(change) > 20 &&
|
||||||
|
value !== undefined &&
|
||||||
|
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"
|
||||||
|
aria-label="查看趋势"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showMetricChart(metric.name, metric.key, data, 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}>
|
||||||
|
{data[0] &&
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'ROE',
|
||||||
|
value: getValueByPath<number>(data[0], 'profitability.roe'),
|
||||||
|
format: 'percent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '毛利率',
|
||||||
|
value: getValueByPath<number>(data[0], 'profitability.gross_margin'),
|
||||||
|
format: 'percent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '净利率',
|
||||||
|
value: getValueByPath<number>(data[0], 'profitability.net_profit_margin'),
|
||||||
|
format: 'percent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '流动比率',
|
||||||
|
value: getValueByPath<number>(data[0], 'solvency.current_ratio'),
|
||||||
|
format: 'decimal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '资产负债率',
|
||||||
|
value: getValueByPath<number>(data[0], 'solvency.asset_liability_ratio'),
|
||||||
|
format: 'percent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '研发费用率',
|
||||||
|
value: getValueByPath<number>(data[0], 'expense_ratios.rd_expense_ratio'),
|
||||||
|
format: 'percent',
|
||||||
|
},
|
||||||
|
].map((item, idx) => (
|
||||||
|
<Box key={idx} p={3} borderRadius="md" bg="gray.50">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialMetricsTable;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* 利润表组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Box,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon, ViewIcon } from '@chakra-ui/icons';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { INCOME_STATEMENT_SECTIONS } from '../constants';
|
||||||
|
import { getValueByPath, isNegativeIndicator } from '../utils';
|
||||||
|
import type { IncomeStatementTableProps } from '../types';
|
||||||
|
|
||||||
|
export const IncomeStatementTable: React.FC<IncomeStatementTableProps> = ({
|
||||||
|
data,
|
||||||
|
showMetricChart,
|
||||||
|
calculateYoYChange,
|
||||||
|
getCellBackground,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
bgColor,
|
||||||
|
hoverBg,
|
||||||
|
}) => {
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
revenue: true,
|
||||||
|
costs: true,
|
||||||
|
otherGains: true,
|
||||||
|
profits: true,
|
||||||
|
eps: true,
|
||||||
|
comprehensive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 数组安全检查
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
暂无利润表数据
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.min(data.length, 6);
|
||||||
|
const displayData = data.slice(0, maxColumns);
|
||||||
|
|
||||||
|
const renderSection = (section: (typeof INCOME_STATEMENT_SECTIONS)[0]) => (
|
||||||
|
<React.Fragment key={section.key}>
|
||||||
|
<Tr
|
||||||
|
bg="gray.50"
|
||||||
|
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 = data.map((item) => getValueByPath<number>(item, metric.path));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={metric.key}
|
||||||
|
_hover={{ bg: hoverBg, cursor: 'pointer' }}
|
||||||
|
onClick={() => showMetricChart(metric.name, metric.key, data, metric.path)}
|
||||||
|
bg={
|
||||||
|
metric.isTotal
|
||||||
|
? 'blue.50'
|
||||||
|
: metric.isSubtotal
|
||||||
|
? 'orange.50'
|
||||||
|
: '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 ?? 0,
|
||||||
|
item.period,
|
||||||
|
data,
|
||||||
|
metric.path
|
||||||
|
);
|
||||||
|
|
||||||
|
// 特殊处理:成本费用类负向指标,增长用绿色,减少用红色
|
||||||
|
const isCostItem = isNegativeIndicator(metric.key);
|
||||||
|
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 !== undefined && 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"
|
||||||
|
aria-label="查看图表"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
{INCOME_STATEMENT_SECTIONS.map((section) => renderSection(section))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncomeStatementTable;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 行业排名组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
SimpleGrid,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { RANKING_METRICS } from '../constants';
|
||||||
|
import type { IndustryRankingViewProps } from '../types';
|
||||||
|
|
||||||
|
export const IndustryRankingView: React.FC<IndustryRankingViewProps> = ({
|
||||||
|
industryRank,
|
||||||
|
bgColor,
|
||||||
|
borderColor,
|
||||||
|
}) => {
|
||||||
|
if (!industryRank || industryRank.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Text textAlign="center" color="gray.500" py={8}>
|
||||||
|
暂无行业排名数据
|
||||||
|
</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack spacing={4} align="stretch">
|
||||||
|
{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}>
|
||||||
|
{RANKING_METRICS.map((metric) => {
|
||||||
|
const metricData = ranking.metrics?.[metric.key as keyof typeof ranking.metrics];
|
||||||
|
if (!metricData) return null;
|
||||||
|
|
||||||
|
const isGood = metricData.rank && metricData.rank <= 10;
|
||||||
|
const isBad = metricData.rank && metricData.rank > 30;
|
||||||
|
|
||||||
|
const isPercentMetric =
|
||||||
|
metric.key.includes('growth') ||
|
||||||
|
metric.key.includes('margin') ||
|
||||||
|
metric.key === 'roe';
|
||||||
|
|
||||||
|
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">
|
||||||
|
{isPercentMetric
|
||||||
|
? 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}>
|
||||||
|
行业均值:{' '}
|
||||||
|
{isPercentMetric
|
||||||
|
? formatUtils.formatPercent(metricData.industry_avg)
|
||||||
|
: metricData.industry_avg?.toFixed(2) || '-'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndustryRankingView;
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 主营业务分析组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import { getMainBusinessPieOption } from '../utils';
|
||||||
|
import type {
|
||||||
|
MainBusinessAnalysisProps,
|
||||||
|
BusinessItem,
|
||||||
|
ProductClassification,
|
||||||
|
IndustryClassification,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const MainBusinessAnalysis: React.FC<MainBusinessAnalysisProps> = ({
|
||||||
|
mainBusiness,
|
||||||
|
}) => {
|
||||||
|
// 优先使用product_classification,如果为空则使用industry_classification
|
||||||
|
const hasProductData =
|
||||||
|
mainBusiness?.product_classification && mainBusiness.product_classification.length > 0;
|
||||||
|
const hasIndustryData =
|
||||||
|
mainBusiness?.industry_classification && 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] as ProductClassification)
|
||||||
|
: (mainBusiness!.industry_classification![0] as IndustryClassification);
|
||||||
|
|
||||||
|
// 获取业务项目
|
||||||
|
const businessItems: BusinessItem[] = hasProductData
|
||||||
|
? (latestPeriod as ProductClassification).products
|
||||||
|
: (latestPeriod as IndustryClassification).industries;
|
||||||
|
|
||||||
|
// 过滤掉"合计"项,准备饼图数据
|
||||||
|
const pieData = businessItems
|
||||||
|
.filter((item: BusinessItem) => item.content !== '合计')
|
||||||
|
.map((item: BusinessItem) => ({
|
||||||
|
name: item.content,
|
||||||
|
value: item.revenue || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pieOption = getMainBusinessPieOption(
|
||||||
|
`主营业务构成 - ${latestPeriod.report_type}`,
|
||||||
|
dataSource === 'industry' ? '按行业分类' : '按产品分类',
|
||||||
|
pieData
|
||||||
|
);
|
||||||
|
|
||||||
|
// 历史对比数据
|
||||||
|
const historicalData = hasProductData
|
||||||
|
? (mainBusiness!.product_classification! as ProductClassification[])
|
||||||
|
: (mainBusiness!.industry_classification! as IndustryClassification[]);
|
||||||
|
|
||||||
|
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: BusinessItem) => item.content !== '合计')
|
||||||
|
.map((item: BusinessItem, idx: number) => (
|
||||||
|
<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: BusinessItem) => item.content !== '合计')
|
||||||
|
.map((item: BusinessItem, idx: number) => (
|
||||||
|
<Tr key={idx}>
|
||||||
|
<Td>{item.content}</Td>
|
||||||
|
{historicalData.slice(0, 3).map((period) => {
|
||||||
|
const periodItems: BusinessItem[] = hasProductData
|
||||||
|
? (period as ProductClassification).products
|
||||||
|
: (period as IndustryClassification).industries;
|
||||||
|
const matchItem = periodItems.find(
|
||||||
|
(p: BusinessItem) => p.content === item.content
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Td key={period.period} isNumeric>
|
||||||
|
{matchItem
|
||||||
|
? formatUtils.formatLargeNumber(matchItem.revenue)
|
||||||
|
: '-'}
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainBusinessAnalysis;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* 股票对比组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
VStack,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Heading,
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Text,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { logger } from '@utils/logger';
|
||||||
|
import { financialService, formatUtils } from '@services/financialService';
|
||||||
|
import { COMPARE_METRICS } from '../constants';
|
||||||
|
import { getValueByPath, getCompareBarChartOption } from '../utils';
|
||||||
|
import type { StockComparisonProps, StockInfo } from '../types';
|
||||||
|
|
||||||
|
interface CompareData {
|
||||||
|
stockInfo: StockInfo;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
metrics: any[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
comparison: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockComparison: React.FC<StockComparisonProps> = ({
|
||||||
|
currentStock,
|
||||||
|
stockInfo,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
}) => {
|
||||||
|
const [compareStock, setCompareStock] = useState('');
|
||||||
|
const [compareData, setCompareData] = useState<CompareData | null>(null);
|
||||||
|
const [compareLoading, setCompareLoading] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const loadCompareData = async () => {
|
||||||
|
if (!compareStock || compareStock.length !== 6) {
|
||||||
|
logger.warn('StockComparison', '无效的对比股票代码', { compareStock });
|
||||||
|
toast({
|
||||||
|
title: '请输入有效的6位股票代码',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('StockComparison', '开始加载对比数据', { 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('StockComparison', '对比数据加载成功', { currentStock, compareStock });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('StockComparison', 'loadCompareData', error, {
|
||||||
|
currentStock,
|
||||||
|
compareStock,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCompareLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
{COMPARE_METRICS.map((metric) => {
|
||||||
|
const value1 = getValueByPath<number>(stockInfo, metric.path);
|
||||||
|
const value2 = getValueByPath<number>(
|
||||||
|
compareData.stockInfo,
|
||||||
|
metric.path
|
||||||
|
);
|
||||||
|
|
||||||
|
let diff: number | null = null;
|
||||||
|
let diffColor = 'gray.500';
|
||||||
|
|
||||||
|
if (value1 !== undefined && value2 !== undefined) {
|
||||||
|
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} justify="flex-end">
|
||||||
|
{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={getCompareBarChartOption(
|
||||||
|
'盈利能力对比',
|
||||||
|
stockInfo?.stock_name || '',
|
||||||
|
compareData.stockInfo?.stock_name || '',
|
||||||
|
['ROE', 'ROA', '毛利率', '净利率'],
|
||||||
|
[
|
||||||
|
stockInfo?.key_metrics?.roe,
|
||||||
|
stockInfo?.key_metrics?.roa,
|
||||||
|
stockInfo?.key_metrics?.gross_margin,
|
||||||
|
stockInfo?.key_metrics?.net_margin,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
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={getCompareBarChartOption(
|
||||||
|
'成长能力对比',
|
||||||
|
stockInfo?.stock_name || '',
|
||||||
|
compareData.stockInfo?.stock_name || '',
|
||||||
|
['营收增长', '利润增长', '资产增长', '股东权益增长'],
|
||||||
|
[
|
||||||
|
stockInfo?.growth_rates?.revenue_growth,
|
||||||
|
stockInfo?.growth_rates?.profit_growth,
|
||||||
|
stockInfo?.growth_rates?.asset_growth,
|
||||||
|
stockInfo?.growth_rates?.equity_growth,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockComparison;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 股票信息头部组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Grid,
|
||||||
|
GridItem,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Heading,
|
||||||
|
Badge,
|
||||||
|
Stat,
|
||||||
|
StatLabel,
|
||||||
|
StatNumber,
|
||||||
|
Alert,
|
||||||
|
AlertIcon,
|
||||||
|
Box,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { formatUtils } from '@services/financialService';
|
||||||
|
import type { StockInfoHeaderProps } from '../types';
|
||||||
|
|
||||||
|
export const StockInfoHeader: React.FC<StockInfoHeaderProps> = ({
|
||||||
|
stockInfo,
|
||||||
|
positiveColor,
|
||||||
|
negativeColor,
|
||||||
|
}) => {
|
||||||
|
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
|
||||||
|
? stockInfo.growth_rates.revenue_growth > 0
|
||||||
|
? positiveColor
|
||||||
|
: negativeColor
|
||||||
|
: 'gray.500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatUtils.formatPercent(stockInfo.growth_rates?.revenue_growth)}
|
||||||
|
</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
</GridItem>
|
||||||
|
<GridItem>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>利润增长</StatLabel>
|
||||||
|
<StatNumber
|
||||||
|
color={
|
||||||
|
stockInfo.growth_rates?.profit_growth
|
||||||
|
? stockInfo.growth_rates.profit_growth > 0
|
||||||
|
? positiveColor
|
||||||
|
: 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockInfoHeader;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 组件统一导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { StockInfoHeader } from './StockInfoHeader';
|
||||||
|
export { BalanceSheetTable } from './BalanceSheetTable';
|
||||||
|
export { IncomeStatementTable } from './IncomeStatementTable';
|
||||||
|
export { CashflowTable } from './CashflowTable';
|
||||||
|
export { FinancialMetricsTable } from './FinancialMetricsTable';
|
||||||
|
export { MainBusinessAnalysis } from './MainBusinessAnalysis';
|
||||||
|
export { IndustryRankingView } from './IndustryRankingView';
|
||||||
|
export { StockComparison } from './StockComparison';
|
||||||
|
export { ComparisonAnalysis } from './ComparisonAnalysis';
|
||||||
Reference in New Issue
Block a user