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:
@@ -155,13 +155,13 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
asset_disposal_income: 340 - i * 10
|
asset_disposal_income: 340 - i * 10
|
||||||
},
|
},
|
||||||
profit: {
|
profit: {
|
||||||
operating_profit: 68450 - i * 1500,
|
operating_profit: (68450 - i * 1500) * 10000,
|
||||||
total_profit: 69500 - i * 1500,
|
total_profit: (69500 - i * 1500) * 10000,
|
||||||
income_tax_expense: 16640 - i * 300,
|
income_tax_expense: (16640 - i * 300) * 10000,
|
||||||
net_profit: 52860 - i * 1200,
|
net_profit: (52860 - i * 1200) * 10000,
|
||||||
parent_net_profit: 51200 - i * 1150,
|
parent_net_profit: (51200 - i * 1150) * 10000,
|
||||||
minority_profit: 1660 - i * 50,
|
minority_profit: (1660 - i * 50) * 10000,
|
||||||
continuing_operations_net_profit: 52860 - i * 1200,
|
continuing_operations_net_profit: (52860 - i * 1200) * 10000,
|
||||||
discontinued_operations_net_profit: 0
|
discontinued_operations_net_profit: 0
|
||||||
},
|
},
|
||||||
non_operating: {
|
non_operating: {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -16,3 +16,6 @@ export { FinancialPanoramaSkeleton } from './FinancialPanoramaSkeleton';
|
|||||||
// 统一财务表格组件
|
// 统一财务表格组件
|
||||||
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
|
export { UnifiedFinancialTable } from './UnifiedFinancialTable';
|
||||||
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';
|
export type { UnifiedFinancialTableProps, TableType, FinancialDataItem } from './UnifiedFinancialTable';
|
||||||
|
// 归母净利润趋势图表
|
||||||
|
export { default as NetProfitTrendChart } from './NetProfitTrendChart';
|
||||||
|
export { default as PeriodFilterDropdown } from './PeriodFilterDropdown';
|
||||||
|
|||||||
@@ -5,3 +5,6 @@
|
|||||||
export { useFinancialData } from './useFinancialData';
|
export { useFinancialData } from './useFinancialData';
|
||||||
export type { DataTypeKey } from './useFinancialData';
|
export type { DataTypeKey } from './useFinancialData';
|
||||||
export type { default as UseFinancialDataReturn } from './useFinancialData';
|
export type { default as UseFinancialDataReturn } from './useFinancialData';
|
||||||
|
|
||||||
|
export { useNetProfitData } from './useNetProfitData';
|
||||||
|
export type { ReportPeriodType, NetProfitDataItem } from './useNetProfitData';
|
||||||
|
|||||||
@@ -252,17 +252,19 @@ export const useFinancialData = (
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 只加载核心数据(概览面板需要的)
|
// 只加载核心数据(概览面板 + 归母净利润趋势图需要的)
|
||||||
const [
|
const [
|
||||||
stockInfoRes,
|
stockInfoRes,
|
||||||
metricsRes,
|
metricsRes,
|
||||||
comparisonRes,
|
comparisonRes,
|
||||||
businessRes,
|
businessRes,
|
||||||
|
incomeRes,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
financialService.getStockInfo(stockCode, options),
|
financialService.getStockInfo(stockCode, options),
|
||||||
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
|
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
|
||||||
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
|
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
|
||||||
financialService.getMainBusiness(stockCode, 4, 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 (comparisonRes.success) setComparison(comparisonRes.data);
|
||||||
if (businessRes.success) setMainBusiness(businessRes.data);
|
if (businessRes.success) setMainBusiness(businessRes.data);
|
||||||
|
if (incomeRes.success) {
|
||||||
|
setIncomeStatement(incomeRes.data);
|
||||||
|
dataPeriodsRef.current.income = selectedPeriods;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
|
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,12 +5,10 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Container,
|
Container,
|
||||||
VStack,
|
VStack,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
Text,
|
|
||||||
Alert,
|
Alert,
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
@@ -38,10 +36,10 @@ import { calculateYoYChange, getCellBackground } from './utils';
|
|||||||
import {
|
import {
|
||||||
PeriodSelector,
|
PeriodSelector,
|
||||||
FinancialOverviewPanel,
|
FinancialOverviewPanel,
|
||||||
MainBusinessAnalysis,
|
|
||||||
ComparisonAnalysis,
|
ComparisonAnalysis,
|
||||||
MetricChartModal,
|
MetricChartModal,
|
||||||
FinancialPanoramaSkeleton,
|
FinancialPanoramaSkeleton,
|
||||||
|
NetProfitTrendChart,
|
||||||
} from './components';
|
} from './components';
|
||||||
import {
|
import {
|
||||||
BalanceSheetTab,
|
BalanceSheetTab,
|
||||||
@@ -184,10 +182,11 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
return (
|
return (
|
||||||
<Container maxW="container.xl" py={5}>
|
<Container maxW="container.xl" py={5}>
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
{/* 财务全景面板(四列布局:成长能力、盈利与回报、风险与运营、主营业务按钮) */}
|
||||||
<FinancialOverviewPanel
|
<FinancialOverviewPanel
|
||||||
stockInfo={stockInfo}
|
stockInfo={stockInfo}
|
||||||
financialMetrics={financialMetrics}
|
financialMetrics={financialMetrics}
|
||||||
|
mainBusiness={mainBusiness}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 营收与利润趋势 */}
|
{/* 营收与利润趋势 */}
|
||||||
@@ -195,14 +194,12 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
<ComparisonAnalysis comparison={comparison} />
|
<ComparisonAnalysis comparison={comparison} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 主营业务 */}
|
{/* 归母净利润趋势分析 */}
|
||||||
{stockInfo && (
|
{incomeStatement && incomeStatement.length > 0 && (
|
||||||
<Box>
|
<NetProfitTrendChart
|
||||||
<Text fontSize="lg" fontWeight="bold" mb={4} color="#D4AF37">
|
incomeStatement={incomeStatement}
|
||||||
主营业务
|
loading={loadingTab === 'income'}
|
||||||
</Text>
|
/>
|
||||||
<MainBusinessAnalysis mainBusiness={mainBusiness} />
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const getMainBusinessPieOption = (
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
|
confine: true,
|
||||||
backgroundColor: chartTheme.tooltip.bg,
|
backgroundColor: chartTheme.tooltip.bg,
|
||||||
borderColor: chartTheme.tooltip.border,
|
borderColor: chartTheme.tooltip.border,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
@@ -276,16 +277,25 @@ export const getMainBusinessPieOption = (
|
|||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: '45%',
|
radius: '50%',
|
||||||
center: ['50%', '45%'],
|
center: ['50%', '42%'],
|
||||||
data: data,
|
data: data,
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
color: '#E2E8F0',
|
color: '#E2E8F0',
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
formatter: '{d}%',
|
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: {
|
labelLine: {
|
||||||
|
length: 12,
|
||||||
|
length2: 8,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: alpha('gold', 0.5),
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
getComparisonChartOption,
|
getComparisonChartOption,
|
||||||
getMainBusinessPieOption,
|
getMainBusinessPieOption,
|
||||||
getCompareBarChartOption,
|
getCompareBarChartOption,
|
||||||
|
getNetProfitTrendChartOption,
|
||||||
} from './chartOptions';
|
} from './chartOptions';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user