Files
my-worker/web-vue/packages/erp/views/erp/green/components/ChartAccount.vue
2026-02-18 18:32:37 +08:00

551 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Card title="年度账户收支趋势" style="width: 100%; height: 100%;">
<div ref="chartDom" style="width: 100%; height: 100%;"></div>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, watchEffect } from 'vue';
import { Card } from 'ant-design-vue';
import { useMessage } from '@jeesite/core/hooks/web/useMessage';
import { BizItemInfo, bizItemInfoListAll } from '@jeesite/biz/api/biz/itemInfo';
import * as echarts from 'echarts';
const props = defineProps<{
formParams: Record<string, any>;
}>();
const rawData = ref<BizItemInfo[]>([]);
const chartDom = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
let resizeTimer: number | null = null;
const { createMessage } = useMessage();
const formatNumber = (num: string | undefined, decimal = 2): number => {
if (!num) return 0;
const parsed = Number(num);
return isNaN(parsed) ? 0 : Number(parsed.toFixed(decimal));
};
const toTenThousandYuan = (num: string | undefined): number => {
const rawNum = formatNumber(num);
return formatNumber((rawNum / 10000).toString());
};
const formatWithThousandsSeparator = (num: string | number | undefined): string => {
const parsed = Number(num);
if (isNaN(parsed)) return '0.00';
return parsed.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
const calculateNetProfit = (income: string | undefined, expense: string | undefined): number => {
const incomeNum = formatNumber(income);
const expenseNum = formatNumber(expense);
return formatNumber((incomeNum - expenseNum).toString());
};
const calculateExpenseRatio = (income: string | undefined, expense: string | undefined): number => {
const incomeNum = formatNumber(income);
const expenseNum = formatNumber(expense);
if (incomeNum === 0) return 0;
const ratio = (expenseNum / incomeNum) * 100;
return formatNumber(ratio.toString(), 2);
};
const sortByMonth = (data: BizItemInfo[]): BizItemInfo[] => {
return data.sort((a, b) => {
const monthA = a.xaxis ? parseInt(a.xaxis, 10) : 0;
const monthB = b.xaxis ? parseInt(b.xaxis, 10) : 0;
return monthA - monthB;
});
};
const calculateYAxisExtent = (data: number[]) => {
if (data.length === 0) return [0, 10];
const min = Math.min(...data);
const max = Math.max(...data);
const minExtent = 0;
if (max === 0) {
return [0, 10];
}
const padding = Math.max(max * 0.1, 1);
const maxExtent = max + padding;
return [minExtent, maxExtent];
};
const calculateRatioYAxisExtent = (data: number[]) => {
if (data.length === 0) return [0, 100];
const min = Math.min(...data);
const max = Math.max(...data);
const minExtent = 0;
const maxExtent = Math.max(max * 1.2, 100);
return [minExtent, maxExtent];
};
const fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_ACCOUNT_Y001",
};
const result = await bizItemInfoListAll(reqParams);
const validData = (result || [])
.filter(item => item.xaxis)
.filter(item => item.index01 || item.index02);
rawData.value = sortByMonth(validData);
} catch (error) {
console.error('获取数据列表失败:', error);
rawData.value = [];
}
};
const initChart = () => {
if (!chartDom.value) return;
if (!chartInstance) {
chartInstance = echarts.init(chartDom.value);
}
if (rawData.value.length === 0) {
chartInstance.setOption({
title: {
left: 'center',
textStyle: { fontSize: 18, color: '#333' }
},
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['收入', '支出', '支出占比'], top: 10, textStyle: { fontSize: 12 } },
grid: {
left: 10,
right: 10,
bottom: 60,
top: 40,
containLabel: true
},
xAxis: {
type: 'category',
data: [],
axisLabel: {
fontSize: 10,
rotate: 60,
overflow: 'truncate',
width: 60,
lineHeight: 1.2
},
axisLine: { onZero: true },
axisTick: {
alignWithLabel: true,
length: 3
}
},
yAxis: [
{
type: 'value',
name: '金额(万元)',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => value.toFixed(2)
},
splitLine: { lineStyle: { color: '#e8e8e8' } },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: false,
min: 0
},
{
type: 'value',
name: '占比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(2)}%`
},
splitLine: { show: false },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: true,
position: 'right'
}
],
series: [],
noDataLoadingOption: {
text: '暂无收入支出数据',
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
position: 'center',
effect: 'none'
}
}, true);
return;
}
const xAxisData = rawData.value.map(item => item.xaxis);
const incomeData = rawData.value.map(item => toTenThousandYuan(item.index01));
const expenseData = rawData.value.map(item => toTenThousandYuan(item.index02));
const ratioData = rawData.value.map(item => calculateExpenseRatio(item.index01, item.index02));
const amountData = [...incomeData, ...expenseData];
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
const [ratioMin, ratioMax] = calculateRatioYAxisExtent(ratioData);
const barLabelConfig = {
show: true,
position: 'top',
distance: 3,
textStyle: {
fontSize: 10,
color: '#333',
fontWeight: '500'
},
formatter: (params: any) => `${params.value.toFixed(2)}`
};
const option = {
title: {
left: 'center',
textStyle: { fontSize: 18, color: '#333' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontSize: 12 },
padding: 12,
backgroundColor: '#fff',
borderColor: '#e8e8e8',
borderWidth: 1,
formatter: (params: any[]) => {
if (!params || params.length === 0) return '<div style="padding: 8px;">暂无数据</div>';
const currentXAxisValue = params[0]?.axisValue || '';
const item = rawData.value.find(i => i.xaxis === currentXAxisValue);
if (!item) return `<div style="padding: 8px;">${currentXAxisValue}:暂无明细</div>`;
const netProfit = calculateNetProfit(item.index01, item.index02);
const ratio = calculateExpenseRatio(item.index01, item.index02);
const netProfitColor = netProfit > 0 ? '#52c41a' : netProfit < 0 ? '#f5222d' : '#666';
return `
<div style="font-weight: 600; color: #333; margin-bottom: 8px; text-align: center;">${currentXAxisValue}</div>
<table style="width: 100%; border-collapse: collapse; font-size: 11px; min-width: 400px; margin-bottom: 8px;">
<thead>
<tr style="background-color: #f8f8f8;">
<th style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; font-weight: 600;">总收入</th>
<th style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; font-weight: 600;">总支出</th>
<th style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; font-weight: 600;">净收益</th>
<th style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; font-weight: 600;">支出占比</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #1890ff;">${formatWithThousandsSeparator(item.index01)} 元</td>
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #f5222d;">${formatWithThousandsSeparator(item.index02)} 元</td>
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: ${netProfitColor};">${formatWithThousandsSeparator(netProfit)} 元</td>
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #fa8c16;">${ratio.toFixed(2)}%</td>
</tr>
</tbody>
</table>
`;
}
},
legend: {
data: ['收入', '支出', '支出占比'],
top: 10,
left: 'center',
textStyle: { fontSize: 11 }
},
grid: {
left: 10,
right: 50,
bottom: 60,
top: 40,
containLabel: true
},
xAxis: {
type: 'category',
data: xAxisData,
axisLabel: {
fontSize: 10,
rotate: 60,
overflow: 'truncate',
width: 60,
lineHeight: 1.2,
margin: 8
},
axisLine: { onZero: true },
axisTick: {
alignWithLabel: true,
length: 3
},
boundaryGap: true
},
yAxis: [
{
type: 'value',
name: '金额(万元)',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => value.toFixed(2)
},
min: amountMin,
max: amountMax,
axisLine: { onZero: true },
splitLine: { lineStyle: { color: '#e8e8e8' } },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: false,
minInterval: 0.1,
interval: 'auto'
},
{
type: 'value',
name: '占比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(2)}%`
},
min: ratioMin,
max: ratioMax,
axisLine: { onZero: true },
splitLine: { show: false },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: true,
position: 'right'
}
],
noDataLoadingOption: {
text: '暂无数据',
textStyle: { fontSize: 14, color: '#999' },
effect: 'bubble',
effectOption: { effect: { n: 0 } }
},
series: [
{
name: '收入',
type: 'bar',
data: incomeData,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#1890ff' },
{ offset: 1, color: '#096dd9' }
]),
borderRadius: [6, 6, 0, 0]
},
barWidth: 12,
barGap: '30%',
barCategoryGap: '20%',
label: barLabelConfig,
yAxisIndex: 0,
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#40a9ff' },
{ offset: 1, color: '#1890ff' }
])
}
}
},
{
name: '支出',
type: 'bar',
data: expenseData,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#f5222d' },
{ offset: 1, color: '#cf1322' }
]),
borderRadius: [6, 6, 0, 0]
},
barWidth: 12,
label: barLabelConfig,
yAxisIndex: 0,
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ff4d4f' },
{ offset: 0, color: '#f5222d' }
])
}
}
},
{
name: '支出占比',
type: 'line',
data: ratioData,
yAxisIndex: 1,
smooth: true,
symbol: 'circle',
symbolSize: 5,
lineStyle: {
width: 1,
color: '#fa8c16'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(250, 140, 22, 0.25)' },
{ offset: 1, color: 'rgba(250, 140, 22, 0.04)' }
])
},
label: {
show: true,
position: 'top',
distance: 4,
textStyle: {
fontSize: 9,
color: '#fa8c16',
fontWeight: 500
},
formatter: (params: any) => `${params.value.toFixed(2)}%`
},
emphasis: {
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 1.5
}
}
}
],
animationDuration: 500,
animationEasingUpdate: 'quinticInOut'
};
chartInstance.setOption(option, true);
};
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize({
animation: {
duration: 300,
easing: 'quadraticInOut'
}
});
}
};
const debounceResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resizeChart, 100);
};
watch(
() => props.formParams,
(newParams) => {
if (Object.keys(newParams).length) {
fetchDataList(newParams);
}
},
{ deep: true, immediate: true }
);
watchEffect(() => {
if (rawData.value || chartDom.value) {
initChart();
}
});
onMounted(() => {
fetchDataList(props.formParams);
window.addEventListener('resize', debounceResize);
});
onUnmounted(() => {
if (resizeTimer) clearTimeout(resizeTimer);
window.removeEventListener('resize', debounceResize);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.status-filter-container {
display: flex;
align-items: center;
gap: 16px;
}
.status-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 14px;
}
.status-item {
cursor: pointer;
color: #666;
position: relative;
padding-bottom: 2px;
transition: all 0.2s ease;
user-select: none;
}
.status-item.active {
color: #1890ff;
font-weight: 500;
}
.status-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: #1890ff;
border-radius: 1px;
}
.status-item:not(.active):hover {
color: #40a9ff;
}
:deep(.ant-card) {
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
padding: 0 !important;
margin: 0 !important;
}
:deep(.ant-card:hover) {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
:deep(.ant-card-body) {
padding: 0 !important;
height: 100%;
}
:deep(.echarts-tooltip) {
border-radius: 6px;
padding: 10px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
border: 1px solid #e8e8e8 !important;
max-width: 450px;
font-family: 'Microsoft YaHei', sans-serif;
}
:deep(.echarts-xaxis-label) {
font-family: 'Microsoft YaHei', sans-serif;
line-height: 1.2;
}
:deep(.echarts-yaxis-name) {
margin-right: 3px;
}
:deep(.echarts-legend-item) {
margin-right: 12px !important;
}
:deep(.echarts-label) {
font-family: 'Microsoft YaHei', sans-serif;
white-space: nowrap;
}
</style>