579 lines
17 KiB
Vue
579 lines
17 KiB
Vue
<template>
|
|
<Card title="年度账户趋势" class="account-trend-card">
|
|
<div ref="chartDom" class="chart-container"></div>
|
|
</Card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, watchEffect } from 'vue';
|
|
import { Card } from 'ant-design-vue';
|
|
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 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({
|
|
backgroundColor: 'transparent',
|
|
title: {
|
|
left: 'center',
|
|
textStyle: { fontSize: 18, color: '#a0cfff' }
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'shadow', lineStyle: { color: '#40c4ff' } },
|
|
backgroundColor: 'rgba(9, 30, 58, 0.95)',
|
|
borderColor: 'rgba(32, 160, 255, 0.3)',
|
|
textStyle: { color: '#fff' }
|
|
},
|
|
legend: {
|
|
data: ['收入', '支出', '支出占比'],
|
|
top: 10,
|
|
textStyle: { fontSize: 12, color: '#a0cfff' }
|
|
},
|
|
grid: {
|
|
left: 10,
|
|
right: 20,
|
|
bottom: 12,
|
|
top: 40,
|
|
containLabel: true
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: [],
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
rotate: 45,
|
|
overflow: 'truncate',
|
|
width: 50,
|
|
lineHeight: 1.2,
|
|
color: '#a0cfff'
|
|
},
|
|
axisLine: { onZero: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
axisTick: {
|
|
alignWithLabel: true,
|
|
length: 3,
|
|
lineStyle: { color: 'rgba(32, 160, 255, 0.3)' }
|
|
}
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '金额(万元)',
|
|
nameTextStyle: { fontSize: 11, color: '#a0cfff' },
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
formatter: (value: number) => value.toFixed(2),
|
|
color: '#a0cfff'
|
|
},
|
|
splitLine: { lineStyle: { color: 'rgba(32, 160, 255, 0.1)' } },
|
|
axisTick: { alignWithLabel: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitNumber: 5,
|
|
scale: false,
|
|
min: 0
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '%',
|
|
nameTextStyle: { fontSize: 11, color: '#a0cfff' },
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
formatter: (value: number) => `${value.toFixed(2)}%`,
|
|
color: '#a0cfff'
|
|
},
|
|
splitLine: { show: false },
|
|
axisTick: { alignWithLabel: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitNumber: 5,
|
|
scale: true,
|
|
position: 'right'
|
|
}
|
|
],
|
|
series: [],
|
|
noDataLoadingOption: {
|
|
text: '暂无数据',
|
|
textStyle: { fontSize: 16, color: '#a0cfff', 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: '#40c4ff',
|
|
fontWeight: '500',
|
|
textShadowColor: 'rgba(0,0,0,0.3)',
|
|
textShadowBlur: 1
|
|
},
|
|
formatter: (params: any) => `${params.value.toFixed(2)}`
|
|
};
|
|
|
|
const option = {
|
|
backgroundColor: 'transparent',
|
|
title: {
|
|
left: 'center',
|
|
textStyle: { fontSize: 18, color: '#20a0ff' }
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'shadow', lineStyle: { color: '#40c4ff' } },
|
|
textStyle: { fontSize: 12, color: '#fff' },
|
|
padding: 12,
|
|
backgroundColor: 'rgba(9, 30, 58, 0.95)',
|
|
borderColor: 'rgba(32, 160, 255, 0.3)',
|
|
borderWidth: 1,
|
|
formatter: (params: any[]) => {
|
|
if (!params || params.length === 0) return '<div style="padding: 8px; color: #a0cfff;">暂无数据</div>';
|
|
const currentXAxisValue = params[0]?.axisValue || '';
|
|
const item = rawData.value.find(i => i.xaxis === currentXAxisValue);
|
|
if (!item) return `<div style="padding: 8px; color: #a0cfff;">${currentXAxisValue}:暂无明细</div>`;
|
|
const netProfit = calculateNetProfit(item.index01, item.index02);
|
|
const ratio = calculateExpenseRatio(item.index01, item.index02);
|
|
const netProfitColor = netProfit > 0 ? '#40c4ff' : netProfit < 0 ? '#ff4d4f' : '#a0cfff';
|
|
return `
|
|
<div style="font-weight: 600; color: #40c4ff; 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: rgba(18, 60, 110, 0.5);">
|
|
<th style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; font-weight: 600; color: #a0cfff;">总收入</th>
|
|
<th style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; font-weight: 600; color: #a0cfff;">总支出</th>
|
|
<th style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; font-weight: 600; color: #a0cfff;">净收益</th>
|
|
<th style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; font-weight: 600; color: #a0cfff;">支出占比</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; color: #40c4ff;">${formatWithThousandsSeparator(item.index01)} 元</td>
|
|
<td style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; color: #ff4d4f;">${formatWithThousandsSeparator(item.index02)} 元</td>
|
|
<td style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; color: ${netProfitColor};">${formatWithThousandsSeparator(netProfit)} 元</td>
|
|
<td style="padding: 8px; border: 1px solid rgba(32, 160, 255, 0.3); text-align: center; color: #ffa066;">${ratio.toFixed(2)}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
},
|
|
legend: {
|
|
data: ['收入', '支出', '支出占比'],
|
|
top: 10,
|
|
left: 'center',
|
|
textStyle: { fontSize: 11, color: '#a0cfff' }
|
|
},
|
|
grid: {
|
|
left: 10,
|
|
right: 20,
|
|
bottom: 12,
|
|
top: 40,
|
|
containLabel: true
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: xAxisData,
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
rotate: 45,
|
|
overflow: 'truncate',
|
|
width: 50,
|
|
lineHeight: 1.2,
|
|
margin: 6,
|
|
color: '#a0cfff'
|
|
},
|
|
axisLine: { onZero: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
axisTick: {
|
|
alignWithLabel: true,
|
|
length: 3,
|
|
lineStyle: { color: 'rgba(32, 160, 255, 0.3)' }
|
|
},
|
|
boundaryGap: true
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '金额(万元)',
|
|
nameTextStyle: { fontSize: 11, color: '#a0cfff' },
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
formatter: (value: number) => value.toFixed(2),
|
|
color: '#a0cfff'
|
|
},
|
|
min: amountMin,
|
|
max: amountMax,
|
|
axisLine: { onZero: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitLine: { lineStyle: { color: 'rgba(32, 160, 255, 0.1)' } },
|
|
axisTick: { alignWithLabel: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitNumber: 5,
|
|
scale: false,
|
|
minInterval: 0.1,
|
|
interval: 'auto'
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '%',
|
|
nameTextStyle: { fontSize: 11, color: '#a0cfff' },
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
formatter: (value: number) => `${value.toFixed(2)}%`,
|
|
color: '#a0cfff'
|
|
},
|
|
min: ratioMin,
|
|
max: ratioMax,
|
|
axisLine: { onZero: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitLine: { show: false },
|
|
axisTick: { alignWithLabel: true, lineStyle: { color: 'rgba(32, 160, 255, 0.3)' } },
|
|
splitNumber: 5,
|
|
scale: true,
|
|
position: 'right'
|
|
}
|
|
],
|
|
noDataLoadingOption: {
|
|
text: '暂无数据',
|
|
textStyle: { fontSize: 14, color: '#a0cfff' },
|
|
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: '#40c4ff' },
|
|
{ offset: 1, color: '#096dd9' }
|
|
]),
|
|
borderRadius: [6, 6, 0, 0],
|
|
borderColor: 'rgba(255,255,255,0.2)',
|
|
borderWidth: 0.5
|
|
},
|
|
barWidth: 12,
|
|
barGap: '30%',
|
|
barCategoryGap: '20%',
|
|
label: barLabelConfig,
|
|
yAxisIndex: 0,
|
|
emphasis: {
|
|
itemStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: '#69b1ff' },
|
|
{ offset: 1, color: '#40c4ff' }
|
|
]),
|
|
shadowColor: 'rgba(64, 196, 255, 0.5)',
|
|
shadowBlur: 5
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: '支出',
|
|
type: 'bar',
|
|
data: expenseData,
|
|
itemStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: '#ff4d4f' },
|
|
{ offset: 1, color: '#cf1322' }
|
|
]),
|
|
borderRadius: [6, 6, 0, 0],
|
|
borderColor: 'rgba(255,255,255,0.2)',
|
|
borderWidth: 0.5
|
|
},
|
|
barWidth: 12,
|
|
label: barLabelConfig,
|
|
yAxisIndex: 0,
|
|
emphasis: {
|
|
itemStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: '#ff7875' },
|
|
{ offset: 1, color: '#ff4d4f' }
|
|
]),
|
|
shadowColor: 'rgba(255, 77, 79, 0.5)',
|
|
shadowBlur: 5
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: '支出占比',
|
|
type: 'line',
|
|
data: ratioData,
|
|
yAxisIndex: 1,
|
|
smooth: true,
|
|
symbol: 'circle',
|
|
symbolSize: 5,
|
|
lineStyle: {
|
|
width: 1,
|
|
color: '#ffa066'
|
|
},
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: 'rgba(255, 160, 102, 0.3)' },
|
|
{ offset: 1, color: 'rgba(255, 160, 102, 0.05)' }
|
|
])
|
|
},
|
|
label: {
|
|
show: true,
|
|
position: 'top',
|
|
distance: 4,
|
|
textStyle: {
|
|
fontSize: 9,
|
|
color: '#ffa066',
|
|
fontWeight: 500,
|
|
textShadowColor: 'rgba(0,0,0,0.3)',
|
|
textShadowBlur: 1
|
|
},
|
|
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>
|
|
.account-trend-card {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
box-sizing: border-box !important;
|
|
border-radius: 4px !important;
|
|
box-shadow: 0 0 15px rgba(32, 160, 255, 0.1) !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
background: rgba(9, 30, 58, 0.8) !important;
|
|
border: 1px solid rgba(32, 160, 255, 0.3) !important;
|
|
}
|
|
|
|
:deep(.ant-card-head) {
|
|
padding: 0 16px !important;
|
|
border-bottom: 1px solid rgba(32, 160, 255, 0.2) !important;
|
|
height: 56px !important;
|
|
line-height: 56px !important;
|
|
background: rgba(18, 60, 110, 0.7) !important;
|
|
border-radius: 8px 8px 0 0 !important;
|
|
margin: 0 !important;
|
|
}
|
|
|
|
:deep(.ant-card-head-title) {
|
|
color: #20a0ff !important;
|
|
font-weight: 600 !important;
|
|
text-shadow: 0 0 5px rgba(32, 160, 255, 0.2) !important;
|
|
}
|
|
|
|
:deep(.ant-card-body) {
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
height: calc(100% - 56px) !important;
|
|
width: 100% !important;
|
|
box-sizing: border-box !important;
|
|
overflow: hidden !important;
|
|
background: transparent !important;
|
|
border: none !important;
|
|
}
|
|
|
|
.chart-container {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
min-height: 0 !important;
|
|
box-sizing: border-box !important;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
:deep(.echarts-tooltip) {
|
|
border-radius: 6px;
|
|
padding: 10px;
|
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
|
border: 1px solid rgba(32, 160, 255, 0.3) !important;
|
|
max-width: 450px;
|
|
font-family: 'Microsoft YaHei', sans-serif;
|
|
background: rgba(9, 30, 58, 0.95) !important;
|
|
}
|
|
|
|
:deep(.echarts-xaxis-label) {
|
|
font-family: 'Microsoft YaHei', sans-serif;
|
|
line-height: 1.2;
|
|
color: #a0cfff !important;
|
|
}
|
|
|
|
:deep(.echarts-yaxis-name) {
|
|
margin-right: 3px;
|
|
color: #a0cfff !important;
|
|
}
|
|
|
|
:deep(.echarts-legend-item) {
|
|
margin-right: 12px !important;
|
|
color: #a0cfff !important;
|
|
}
|
|
|
|
:deep(.echarts-label) {
|
|
font-family: 'Microsoft YaHei', sans-serif;
|
|
white-space: nowrap;
|
|
color: #40c4ff !important;
|
|
}
|
|
|
|
.status-filter-container,
|
|
.status-filter,
|
|
.status-item {
|
|
display: none !important;
|
|
}
|
|
</style> |