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:
zdl
2025-12-12 15:01:47 +08:00
parent d9106bf9f7
commit b9ea08e601
10 changed files with 1639 additions and 0 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';