feat(FinancialPanorama): 新增归母净利润趋势图组件

- 新增 NetProfitTrendChart 组件,展示归母净利润季度/年度趋势
- 新增 PeriodFilterDropdown 组件,支持季度/年度筛选
- 新增 useNetProfitData Hook,处理净利润数据和同比计算
- 新增 getNetProfitTrendChartOption 图表配置
- useFinancialData 初始加载时同时获取利润表数据
- 修复 mock 数据 profit 字段单位(添加 *10000)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-29 15:00:50 +08:00
parent 22a94c98d2
commit d7ae2e9519
10 changed files with 621 additions and 24 deletions

View File

@@ -155,13 +155,13 @@ export const generateFinancialData = (stockCode) => {
asset_disposal_income: 340 - i * 10
},
profit: {
operating_profit: 68450 - i * 1500,
total_profit: 69500 - i * 1500,
income_tax_expense: 16640 - i * 300,
net_profit: 52860 - i * 1200,
parent_net_profit: 51200 - i * 1150,
minority_profit: 1660 - i * 50,
continuing_operations_net_profit: 52860 - i * 1200,
operating_profit: (68450 - i * 1500) * 10000,
total_profit: (69500 - i * 1500) * 10000,
income_tax_expense: (16640 - i * 300) * 10000,
net_profit: (52860 - i * 1200) * 10000,
parent_net_profit: (51200 - i * 1150) * 10000,
minority_profit: (1660 - i * 50) * 10000,
continuing_operations_net_profit: (52860 - i * 1200) * 10000,
discontinued_operations_net_profit: 0
},
non_operating: {

View File

@@ -0,0 +1,163 @@
/**
* 归母净利润趋势分析图表
* 柱状图(净利润)+ 折线图(同比)+ 筛选器 + 查看详细数据
*/
import React, { useState, useMemo, memo, useCallback } from 'react';
import {
Box,
HStack,
Text,
Link,
Skeleton,
useDisclosure,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { ExternalLink } from 'lucide-react';
import PeriodFilterDropdown from './PeriodFilterDropdown';
import { MetricChartModal } from './MetricChartModal';
import { useNetProfitData, type ReportPeriodType } from '../hooks/useNetProfitData';
import { getNetProfitTrendChartOption } from '../utils/chartOptions';
import type { IncomeStatementData } from '../types';
/** 主题配置 */
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
textPrimary: '#E2E8F0',
textSecondary: '#A0AEC0',
cardBg: 'transparent',
};
export interface NetProfitTrendChartProps {
/** 利润表数据 */
incomeStatement: IncomeStatementData[];
/** 加载状态 */
loading?: boolean;
}
const NetProfitTrendChart: React.FC<NetProfitTrendChartProps> = ({
incomeStatement,
loading = false,
}) => {
// 筛选状态
const [viewMode, setViewMode] = useState<'quarterly' | 'annual'>('annual');
const [annualType, setAnnualType] = useState<ReportPeriodType>('annual');
// 详情弹窗状态
const { isOpen, onOpen, onClose } = useDisclosure();
// 根据视图模式决定筛选类型
const filterType = useMemo(() => {
if (viewMode === 'quarterly') {
return 'all'; // 单季度显示所有数据
}
return annualType;
}, [viewMode, annualType]);
// 获取处理后的数据
const netProfitData = useNetProfitData(incomeStatement, filterType);
// 生成图表配置
const chartOption = useMemo(() => {
if (!netProfitData || netProfitData.length === 0) return null;
return getNetProfitTrendChartOption(netProfitData);
}, [netProfitData]);
// 处理查看详细数据
const handleViewDetail = useCallback(() => {
onOpen();
}, [onOpen]);
// 转换为 MetricChartModal 需要的数据格式
const modalData = useMemo(() => {
return incomeStatement.map((item) => ({
period: item.period,
profit: item.profit,
}));
}, [incomeStatement]);
// 加载状态
if (loading) {
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
<HStack justify="space-between" mb={4}>
<Skeleton height="24px" width="150px" />
<Skeleton height="32px" width="200px" />
</HStack>
<Skeleton height="350px" />
</Box>
);
}
// 无数据状态
if (!netProfitData || netProfitData.length === 0) {
return null;
}
return (
<Box
bg={THEME.cardBg}
border="1px solid"
borderColor={THEME.goldBorder}
borderRadius="md"
p={4}
>
{/* 头部:标题 + 筛选器 */}
<HStack justify="space-between" mb={4}>
<HStack spacing={3}>
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
</Text>
<Link
fontSize="sm"
color={THEME.gold}
_hover={{ textDecoration: 'underline' }}
onClick={handleViewDetail}
cursor="pointer"
>
<HStack spacing={1}>
<Text></Text>
<ExternalLink size={12} />
</HStack>
</Link>
</HStack>
<PeriodFilterDropdown
viewMode={viewMode}
annualType={annualType}
onViewModeChange={setViewMode}
onAnnualTypeChange={setAnnualType}
/>
</HStack>
{/* 图表 */}
{chartOption && (
<ReactECharts
option={chartOption}
style={{ height: '350px', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
)}
{/* 详细数据弹窗 */}
<MetricChartModal
isOpen={isOpen}
onClose={onClose}
metricName="归母净利润"
data={modalData}
dataPath="profit.parent_net_profit"
/>
</Box>
);
};
export default memo(NetProfitTrendChart);

View File

@@ -0,0 +1,116 @@
/**
* 期数筛选下拉组件
* 支持:单季度 | 年报(含子选项:全部、年报、中报、一季报、三季报)
*/
import React, { memo } from 'react';
import {
HStack,
Button,
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react';
import { ChevronDown } from 'lucide-react';
import type { ReportPeriodType } from '../hooks/useNetProfitData';
/** 筛选选项配置 */
const FILTER_OPTIONS: { value: ReportPeriodType; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'annual', label: '年报' },
{ value: 'mid', label: '中报' },
{ value: 'q1', label: '一季报' },
{ value: 'q3', label: '三季报' },
];
/** 主题配置 */
const THEME = {
gold: '#D4AF37',
goldLight: 'rgba(212, 175, 55, 0.1)',
goldBorder: 'rgba(212, 175, 55, 0.3)',
textPrimary: '#E2E8F0',
textSecondary: '#A0AEC0',
menuBg: 'rgba(26, 32, 44, 0.98)',
};
export interface PeriodFilterDropdownProps {
/** 当前视图模式:单季度 | 年报 */
viewMode: 'quarterly' | 'annual';
/** 年报类型筛选 */
annualType: ReportPeriodType;
/** 视图模式变化回调 */
onViewModeChange: (mode: 'quarterly' | 'annual') => void;
/** 年报类型变化回调 */
onAnnualTypeChange: (type: ReportPeriodType) => void;
}
const PeriodFilterDropdown: React.FC<PeriodFilterDropdownProps> = ({
viewMode,
annualType,
onViewModeChange,
onAnnualTypeChange,
}) => {
const currentLabel = FILTER_OPTIONS.find((o) => o.value === annualType)?.label || '年报';
return (
<HStack spacing={2}>
{/* 单季度按钮 */}
<Button
size="sm"
variant={viewMode === 'quarterly' ? 'solid' : 'ghost'}
bg={viewMode === 'quarterly' ? THEME.goldLight : 'transparent'}
color={viewMode === 'quarterly' ? THEME.gold : THEME.textSecondary}
borderWidth="1px"
borderColor={viewMode === 'quarterly' ? THEME.goldBorder : 'transparent'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
onClick={() => onViewModeChange('quarterly')}
>
</Button>
{/* 年报下拉菜单 */}
<Menu>
<MenuButton
as={Button}
size="sm"
variant={viewMode === 'annual' ? 'solid' : 'ghost'}
bg={viewMode === 'annual' ? THEME.goldLight : 'transparent'}
color={viewMode === 'annual' ? THEME.gold : THEME.textSecondary}
borderWidth="1px"
borderColor={viewMode === 'annual' ? THEME.goldBorder : 'transparent'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
rightIcon={<ChevronDown size={14} />}
onClick={() => onViewModeChange('annual')}
>
{currentLabel}
</MenuButton>
<MenuList
bg={THEME.menuBg}
borderColor={THEME.goldBorder}
minW="100px"
py={1}
>
{FILTER_OPTIONS.map((option) => (
<MenuItem
key={option.value}
bg="transparent"
color={annualType === option.value ? THEME.gold : THEME.textPrimary}
fontWeight={annualType === option.value ? 'bold' : 'normal'}
_hover={{ bg: THEME.goldLight, color: THEME.gold }}
onClick={() => {
onAnnualTypeChange(option.value);
onViewModeChange('annual');
}}
>
<Text fontSize="sm">{option.label}</Text>
</MenuItem>
))}
</MenuList>
</Menu>
</HStack>
);
};
export default memo(PeriodFilterDropdown);

View File

@@ -16,3 +16,6 @@ export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
// 统一财务表格组件
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';
// 归母净利润趋势图表
export { default as NetProfitTrendChart } from './NetProfitTrendChart';
export { default as PeriodFilterDropdown } from './PeriodFilterDropdown';

View File

@@ -5,3 +5,6 @@
export { useFinancialData } from './useFinancialData';
export type { DataTypeKey } from './useFinancialData';
export type { default as UseFinancialDataReturn } from './useFinancialData';
export { useNetProfitData } from './useNetProfitData';
export type { ReportPeriodType, NetProfitDataItem } from './useNetProfitData';

View File

@@ -252,17 +252,19 @@ export const useFinancialData = (
setError(null);
try {
// 只加载核心数据(概览面板需要的)
// 只加载核心数据(概览面板 + 归母净利润趋势图需要的)
const [
stockInfoRes,
metricsRes,
comparisonRes,
businessRes,
incomeRes,
] = await Promise.all([
financialService.getStockInfo(stockCode, options),
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
financialService.getMainBusiness(stockCode, 4, options),
financialService.getIncomeStatement(stockCode, selectedPeriods, options),
]);
// 设置数据
@@ -273,6 +275,10 @@ export const useFinancialData = (
}
if (comparisonRes.success) setComparison(comparisonRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
if (incomeRes.success) {
setIncomeStatement(incomeRes.data);
dataPeriodsRef.current.income = selectedPeriods;
}
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
} catch (err) {

View File

@@ -0,0 +1,131 @@
/**
* 归母净利润数据处理 Hook
* 从利润表提取归母净利润并计算同比增长率
*/
import { useMemo } from 'react';
import { formatUtils } from '@services/financialService';
import type { IncomeStatementData } from '../types';
/** 报告期类型筛选 */
export type ReportPeriodType = 'all' | 'annual' | 'mid' | 'q1' | 'q3';
/** 净利润数据项 */
export interface NetProfitDataItem {
period: string; // 原始期间 "20231231"
periodLabel: string; // 显示标签 "2023年报"
netProfit: number; // 归母净利润(亿)
yoyGrowth: number | null;// 同比增长率(%
}
/**
* 获取报告期类型
* @param period 期间字符串 "20231231" 或 "2023-12-31"
* @returns 报告类型 'annual' | 'mid' | 'q1' | 'q3'
*/
const getReportPeriodType = (period: string): ReportPeriodType => {
// 兼容两种格式20231231 或 2023-12-31
const normalized = period.replace(/-/g, '');
const month = normalized.slice(4, 6);
switch (month) {
case '12':
return 'annual';
case '06':
return 'mid';
case '03':
return 'q1';
case '09':
return 'q3';
default:
return 'annual';
}
};
/**
* 查找同比数据(去年同期)
* 兼容两种格式20231231 或 2023-12-31
*/
const findYoYData = (
data: IncomeStatementData[],
currentPeriod: string
): IncomeStatementData | undefined => {
// 检测是否带有连字符
const hasHyphen = currentPeriod.includes('-');
const normalized = currentPeriod.replace(/-/g, '');
const currentYear = parseInt(normalized.slice(0, 4), 10);
const currentMonthDay = normalized.slice(4);
// 根据原格式构建去年同期
const lastYearPeriod = hasHyphen
? `${currentYear - 1}-${currentMonthDay.slice(0, 2)}-${currentMonthDay.slice(2)}`
: `${currentYear - 1}${currentMonthDay}`;
return data.find((item) => item.period === lastYearPeriod);
};
/**
* 计算同比增长率
*/
const calculateYoYGrowth = (
current: number | undefined,
previous: number | undefined
): number | null => {
if (
current === undefined ||
previous === undefined ||
previous === 0
) {
return null;
}
return ((current - previous) / Math.abs(previous)) * 100;
};
/**
* 归母净利润数据 Hook
* @param incomeStatement 利润表数据
* @param filterType 筛选类型 'all' | 'annual' | 'mid' | 'q1' | 'q3'
*/
export const useNetProfitData = (
incomeStatement: IncomeStatementData[],
filterType: ReportPeriodType = 'all'
): NetProfitDataItem[] => {
return useMemo(() => {
if (!incomeStatement || incomeStatement.length === 0) {
return [];
}
// 按期间排序(升序,旧的在前)
const sortedData = [...incomeStatement].sort((a, b) =>
a.period.localeCompare(b.period)
);
// 筛选数据
const filteredData =
filterType === 'all'
? sortedData
: sortedData.filter(
(item) => getReportPeriodType(item.period) === filterType
);
// 转换数据
return filteredData.map((item) => {
const netProfit = item.profit?.parent_net_profit ?? 0;
// 转换为亿元
const netProfitInBillion = netProfit / 100000000;
// 计算同比
const lastYearItem = findYoYData(incomeStatement, item.period);
const lastYearProfit = lastYearItem?.profit?.parent_net_profit;
const yoyGrowth = calculateYoYGrowth(netProfit, lastYearProfit);
return {
period: item.period,
periodLabel: formatUtils.getReportType(item.period),
netProfit: parseFloat(netProfitInBillion.toFixed(2)),
yoyGrowth: yoyGrowth !== null ? parseFloat(yoyGrowth.toFixed(2)) : null,
};
});
}, [incomeStatement, filterType]);
};
export default useNetProfitData;

View File

@@ -5,12 +5,10 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Container,
VStack,
Card,
CardBody,
Text,
Alert,
AlertIcon,
useDisclosure,
@@ -38,10 +36,10 @@ import { calculateYoYChange, getCellBackground } from './utils';
import {
PeriodSelector,
FinancialOverviewPanel,
MainBusinessAnalysis,
ComparisonAnalysis,
MetricChartModal,
FinancialPanoramaSkeleton,
NetProfitTrendChart,
} from './components';
import {
BalanceSheetTab,
@@ -184,10 +182,11 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
return (
<Container maxW="container.xl" py={5}>
<VStack spacing={6} align="stretch">
{/* 财务全景面板(列布局:成长能力、盈利与回报、风险与运营) */}
{/* 财务全景面板(列布局:成长能力、盈利与回报、风险与运营、主营业务按钮 */}
<FinancialOverviewPanel
stockInfo={stockInfo}
financialMetrics={financialMetrics}
mainBusiness={mainBusiness}
/>
{/* 营收与利润趋势 */}
@@ -195,14 +194,12 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
<ComparisonAnalysis comparison={comparison} />
)}
{/* 主营业务 */}
{stockInfo && (
<Box>
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
</Text>
<MainBusinessAnalysis mainBusiness={mainBusiness} />
</Box>
{/* 归母净利润趋势分析 */}
{incomeStatement && incomeStatement.length > 0 && (
<NetProfitTrendChart
incomeStatement={incomeStatement}
loading={loadingTab === 'income'}
/>
)}
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}

View File

@@ -249,6 +249,7 @@ export const getMainBusinessPieOption = (
},
tooltip: {
trigger: 'item',
confine: true,
backgroundColor: chartTheme.tooltip.bg,
borderColor: chartTheme.tooltip.border,
textStyle: {
@@ -276,16 +277,25 @@ export const getMainBusinessPieOption = (
series: [
{
type: 'pie',
radius: '45%',
center: ['50%', '45%'],
radius: '50%',
center: ['50%', '42%'],
data: data,
label: {
show: true,
color: '#E2E8F0',
fontSize: 10,
formatter: '{d}%',
fontSize: 11,
formatter: (params: { name: string; percent: number }) => {
// 业务名称过长时截断
const name = params.name.length > 6
? params.name.slice(0, 6) + '...'
: params.name;
return `${name}\n${params.percent.toFixed(1)}%`;
},
lineHeight: 14,
},
labelLine: {
length: 12,
length2: 8,
lineStyle: {
color: alpha('gold', 0.5),
},
@@ -379,3 +389,170 @@ export const getCompareBarChartOption = (
],
};
};
/** 净利润数据项接口 */
interface NetProfitDataItem {
periodLabel: string;
netProfit: number;
yoyGrowth: number | null;
}
/**
* 生成归母净利润趋势图表配置 - 黑金主题
* 柱状图(净利润)+ 折线图(同比增长率)
* @param data 净利润数据数组
* @returns ECharts 配置
*/
export const getNetProfitTrendChartOption = (data: NetProfitDataItem[]) => {
const barColor = '#5B8DEF'; // 统一蓝色
const lineColor = '#F6AD55'; // 橙色
const periods = data.map((d) => d.periodLabel);
const profitValues = data.map((d) => d.netProfit);
const growthValues = data.map((d) => d.yoyGrowth);
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.tooltip.bg,
borderColor: chartTheme.tooltip.border,
textStyle: {
color: '#E2E8F0',
},
axisPointer: {
type: 'cross',
crossStyle: {
color: alpha('gold', 0.5),
},
},
formatter: (params: Array<{ seriesName: string; value: number | null; color: string; axisValue: string }>) => {
if (!params || params.length === 0) return '';
const period = params[0].axisValue;
let html = `<div style="font-weight:600;margin-bottom:8px;color:${fui.gold}">${period}</div>`;
params.forEach((item) => {
const value = item.value;
const unit = item.seriesName === '归母净利润' ? '亿' : '%';
const displayValue = value !== null && value !== undefined ? `${value.toFixed(2)}${unit}` : '-';
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0">
<span style="display:flex;align-items:center">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${item.color};margin-right:8px"></span>
${item.seriesName}
</span>
<span style="font-weight:500;margin-left:20px;font-family:'Menlo','Monaco',monospace">${displayValue}</span>
</div>`;
});
return html;
},
},
legend: {
data: [
{ name: '归母净利润', itemStyle: { color: barColor } },
{ name: '同比(右)', itemStyle: { color: lineColor } },
],
bottom: 0,
textStyle: {
color: '#A0AEC0',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '12%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: periods,
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: '#A0AEC0',
rotate: 30,
},
},
yAxis: [
{
type: 'value',
name: '(亿)',
position: 'left',
nameTextStyle: {
color: barColor,
},
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: barColor,
},
splitLine: {
lineStyle: {
color: chartTheme.splitLine,
},
},
},
{
type: 'value',
name: '(%)',
position: 'right',
nameTextStyle: {
color: lineColor,
},
axisLine: {
lineStyle: {
color: chartTheme.axisLine,
},
},
axisLabel: {
color: lineColor,
formatter: '{value}',
},
splitLine: {
show: false,
},
},
],
series: [
{
name: '归母净利润',
type: 'bar',
data: profitValues,
barMaxWidth: 40,
itemStyle: {
color: barColor,
},
label: {
show: true,
position: 'top',
color: '#E2E8F0',
fontSize: 11,
formatter: (params: { value: number }) => {
return params.value?.toFixed(2) ?? '';
},
},
},
{
name: '同比(右)',
type: 'line',
yAxisIndex: 1,
data: growthValues,
smooth: true,
symbol: 'circle',
symbolSize: 8,
itemStyle: {
color: lineColor,
},
lineStyle: {
width: 2,
color: lineColor,
},
},
],
};
};

View File

@@ -15,6 +15,7 @@ export {
getComparisonChartOption,
getMainBusinessPieOption,
getCompareBarChartOption,
getNetProfitTrendChartOption,
} from './chartOptions';
export {