refactor(FinancialPanorama): 添加工具函数模块
计算工具 (calculations.ts): - calculateYoYChange: 同比变化率计算 - getCellBackground: 单元格背景色(红涨绿跌) - getValueByPath: 嵌套路径取值 - isNegativeIndicator: 负向指标判断 图表配置 (chartOptions.ts): - getMetricChartOption: 指标趋势柱状图 - getComparisonChartOption: 营收利润双轴图 - getMainBusinessPieOption: 主营业务饼图 - getCompareBarChartOption: 股票对比柱状图 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 财务计算工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 计算同比变化率
|
||||
* @param currentValue 当前值
|
||||
* @param currentPeriod 当前期间
|
||||
* @param allData 所有数据
|
||||
* @param metricPath 指标路径
|
||||
* @returns 变化率和强度
|
||||
*/
|
||||
export const calculateYoYChange = (
|
||||
currentValue: number | null | undefined,
|
||||
currentPeriod: string,
|
||||
allData: Array<{ period: string; [key: string]: unknown }>,
|
||||
metricPath: string
|
||||
): { change: number; intensity: number } => {
|
||||
if (!currentValue || !currentPeriod) return { change: 0, intensity: 0 };
|
||||
|
||||
// 找到去年同期的数据
|
||||
const currentDate = new Date(currentPeriod);
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
|
||||
// 查找去年同期
|
||||
const lastYearSamePeriod = allData.find((item) => {
|
||||
const itemDate = new Date(item.period);
|
||||
const itemYear = itemDate.getFullYear();
|
||||
const itemMonth = itemDate.getMonth() + 1;
|
||||
return itemYear === currentYear - 1 && itemMonth === currentMonth;
|
||||
});
|
||||
|
||||
if (!lastYearSamePeriod) return { change: 0, intensity: 0 };
|
||||
|
||||
const previousValue = metricPath
|
||||
.split('.')
|
||||
.reduce((obj: unknown, key: string) => {
|
||||
if (obj && typeof obj === 'object') {
|
||||
return (obj as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, lastYearSamePeriod) as number | undefined;
|
||||
|
||||
if (!previousValue || previousValue === 0) return { change: 0, intensity: 0 };
|
||||
|
||||
const change = ((currentValue - previousValue) / Math.abs(previousValue)) * 100;
|
||||
const intensity = Math.min(Math.abs(change) / 50, 1); // 50%变化达到最大强度
|
||||
return { change, intensity };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单元格背景色(中国市场颜色)
|
||||
* @param change 变化率
|
||||
* @param intensity 强度
|
||||
* @returns 背景色
|
||||
*/
|
||||
export const getCellBackground = (change: number, intensity: number): string => {
|
||||
if (change > 0) {
|
||||
return `rgba(239, 68, 68, ${intensity * 0.15})`; // 红色背景,涨
|
||||
} else if (change < 0) {
|
||||
return `rgba(34, 197, 94, ${intensity * 0.15})`; // 绿色背景,跌
|
||||
}
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
/**
|
||||
* 从对象中获取嵌套路径的值
|
||||
* @param obj 对象
|
||||
* @param path 路径(如 'assets.current_assets.cash')
|
||||
* @returns 值
|
||||
*/
|
||||
export const getValueByPath = <T = unknown>(
|
||||
obj: unknown,
|
||||
path: string
|
||||
): T | undefined => {
|
||||
return path.split('.').reduce((current: unknown, key: string) => {
|
||||
if (current && typeof current === 'object') {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj) as T | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否为成本费用类指标(负向指标)
|
||||
* @param key 指标 key
|
||||
* @returns 是否为负向指标
|
||||
*/
|
||||
export const isNegativeIndicator = (key: string): boolean => {
|
||||
return (
|
||||
key.includes('cost') ||
|
||||
key.includes('expense') ||
|
||||
key === 'income_tax' ||
|
||||
key.includes('impairment') ||
|
||||
key.includes('days') ||
|
||||
key.includes('debt_ratio')
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* ECharts 图表配置生成器
|
||||
*/
|
||||
|
||||
import { formatUtils } from '@services/financialService';
|
||||
|
||||
interface ChartDataItem {
|
||||
period: string;
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指标趋势图表配置
|
||||
* @param metricName 指标名称
|
||||
* @param data 图表数据
|
||||
* @returns ECharts 配置
|
||||
*/
|
||||
export const getMetricChartOption = (
|
||||
metricName: string,
|
||||
data: ChartDataItem[]
|
||||
) => {
|
||||
return {
|
||||
title: {
|
||||
text: metricName,
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: Array<{ name: string; value: number }>) => {
|
||||
const value = params[0].value;
|
||||
const formattedValue =
|
||||
value > 10000
|
||||
? formatUtils.formatLargeNumber(value)
|
||||
: value?.toFixed(2);
|
||||
return `${params[0].name}<br/>${metricName}: ${formattedValue}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.period),
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: (value: number) => {
|
||||
if (Math.abs(value) >= 100000000) {
|
||||
return (value / 100000000).toFixed(0) + '亿';
|
||||
} else if (Math.abs(value) >= 10000) {
|
||||
return (value / 10000).toFixed(0) + '万';
|
||||
}
|
||||
return value.toFixed(0);
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.map((d) => d.value),
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number; value: number }) => {
|
||||
const idx = params.dataIndex;
|
||||
if (idx === 0) return '#3182CE';
|
||||
const prevValue = data[idx - 1].value;
|
||||
const currValue = params.value;
|
||||
// 中国市场颜色:红涨绿跌
|
||||
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||
},
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (params: { value: number }) => {
|
||||
const value = params.value;
|
||||
if (Math.abs(value) >= 100000000) {
|
||||
return (value / 100000000).toFixed(1) + '亿';
|
||||
} else if (Math.abs(value) >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万';
|
||||
} else if (Math.abs(value) >= 1) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
return value.toFixed(2);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成营收与利润趋势图表配置
|
||||
* @param revenueData 营收数据
|
||||
* @param profitData 利润数据
|
||||
* @returns ECharts 配置
|
||||
*/
|
||||
export const getComparisonChartOption = (
|
||||
revenueData: { period: string; value: number }[],
|
||||
profitData: { period: string; value: number }[]
|
||||
) => {
|
||||
return {
|
||||
title: {
|
||||
text: '营收与利润趋势',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['营业收入', '净利润'],
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: revenueData.map((d) => d.period),
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '营收(亿)',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '利润(亿)',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '营业收入',
|
||||
type: 'bar',
|
||||
data: revenueData.map((d) => d.value?.toFixed(2)),
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number; value: number }) => {
|
||||
const idx = params.dataIndex;
|
||||
if (idx === 0) return '#3182CE';
|
||||
const prevValue = revenueData[idx - 1].value;
|
||||
const currValue = params.value;
|
||||
// 中国市场颜色
|
||||
return currValue >= prevValue ? '#EF4444' : '#10B981';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: profitData.map((d) => d.value?.toFixed(2)),
|
||||
smooth: true,
|
||||
itemStyle: { color: '#F59E0B' },
|
||||
lineStyle: { width: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成主营业务饼图配置
|
||||
* @param title 标题
|
||||
* @param subtitle 副标题
|
||||
* @param data 饼图数据
|
||||
* @returns ECharts 配置
|
||||
*/
|
||||
export const getMainBusinessPieOption = (
|
||||
title: string,
|
||||
subtitle: string,
|
||||
data: { name: string; value: number }[]
|
||||
) => {
|
||||
return {
|
||||
title: {
|
||||
text: title,
|
||||
subtext: subtitle,
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { name: string; value: number; percent: number }) => {
|
||||
return `${params.name}<br/>营收: ${formatUtils.formatLargeNumber(
|
||||
params.value
|
||||
)}<br/>占比: ${params.percent}%`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'center',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成对比柱状图配置
|
||||
* @param title 标题
|
||||
* @param stockName1 股票1名称
|
||||
* @param stockName2 股票2名称
|
||||
* @param categories X轴分类
|
||||
* @param data1 股票1数据
|
||||
* @param data2 股票2数据
|
||||
* @returns ECharts 配置
|
||||
*/
|
||||
export const getCompareBarChartOption = (
|
||||
title: string,
|
||||
stockName1: string,
|
||||
stockName2: string,
|
||||
categories: string[],
|
||||
data1: (number | undefined)[],
|
||||
data2: (number | undefined)[]
|
||||
) => {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [stockName1, stockName2] },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
},
|
||||
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
||||
series: [
|
||||
{
|
||||
name: stockName1,
|
||||
type: 'bar',
|
||||
data: data1,
|
||||
},
|
||||
{
|
||||
name: stockName2,
|
||||
type: 'bar',
|
||||
data: data2,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 工具函数统一导出
|
||||
*/
|
||||
|
||||
export {
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
getValueByPath,
|
||||
isNegativeIndicator,
|
||||
} from './calculations';
|
||||
|
||||
export {
|
||||
getMetricChartOption,
|
||||
getComparisonChartOption,
|
||||
getMainBusinessPieOption,
|
||||
getCompareBarChartOption,
|
||||
} from './chartOptions';
|
||||
Reference in New Issue
Block a user