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