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