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
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -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 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 type { DataTypeKey } 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);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
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 二级导航 */}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
getComparisonChartOption,
|
||||
getMainBusinessPieOption,
|
||||
getCompareBarChartOption,
|
||||
getNetProfitTrendChartOption,
|
||||
} from './chartOptions';
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user