551 lines
15 KiB
Vue
551 lines
15 KiB
Vue
<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> |