财务门户设计

This commit is contained in:
2026-02-18 18:34:09 +08:00
parent 0a2834c357
commit 39ffb42cf6
18 changed files with 2691 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
<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 { 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 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 fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_CATEGORY_Y001",
};
const result = await bizItemInfoListAll(reqParams);
const validData = (result || []).filter(item => item.xaxis && item.index01);
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',
top: '50%',
text: '暂无支出数据',
textStyle: { fontSize: 18, color: '#333' }
},
tooltip: { trigger: 'item' },
padding: [0, 0, 0, 0]
}, true);
return;
}
const pieData = rawData.value.map(item => ({
name: item.xaxis || '',
value: toTenThousandYuan(item.index01)
})).filter(item => item.value > 0);
const option = {
padding: [0, 0, 0, 0],
tooltip: {
trigger: 'item',
textStyle: { fontSize: 12 },
padding: 12,
backgroundColor: '#fff',
borderColor: '#e8e8e8',
borderWidth: 1,
formatter: (params: any) => `
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">${params.name}</div>
<div style="color: #666;">支出:${params.value.toFixed(2)} 万元</div>
`
},
legend: {
type: 'scroll',
orient: 'horizontal',
top: 5,
left: 'center',
textStyle: {
fontSize: 10,
color: '#333',
cursor: 'pointer'
},
itemGap: 15,
itemWidth: 12,
itemHeight: 12,
selectedMode: true,
scrollDataIndex: 0,
pageButtonPosition: 'end',
pageIconColor: '#1890ff',
pageIconInactiveColor: '#ccc',
pageTextStyle: { fontSize: 9 },
height: 30,
formatter: (name: string) => {
return name.length > 6 ? `${name.substring(0, 6)}...` : name;
}
},
series: [
{
name: '支出',
type: 'pie',
radius: ['30%', '60%'],
center: ['50%', '45%'],
data: pieData,
label: {
show: true,
position: 'outside',
fontSize: 10,
formatter: (params: any) => {
const shortName = params.name.length > 4 ? `${params.name.substring(0, 4)}...` : params.name;
return `${shortName}: ${params.value}万元`;
}
},
itemStyle: {
borderColor: '#fff',
borderWidth: 2
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
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);
padding: 0 !important;
margin: 0 !important;
height: 100%;
}
:deep(.ant-card-body) {
padding: 0 !important;
height: 100%;
}
:deep(.echarts-legend) {
display: block !important;
z-index: 999 !important;
padding: 0 0 0 5px !important;
margin: 0 !important;
text-align: center !important;
}
:deep(.echarts-legend-scroll) {
overflow: hidden !important;
white-space: nowrap !important;
height: 30px !important;
line-height: 30px !important;
}
:deep(.echarts-legend-item) {
display: inline-block !important;
margin-right: 15px !important;
white-space: nowrap !important;
cursor: pointer !important;
}
:deep(.echarts-tooltip) {
border-radius: 6px;
padding: 10px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
border: 1px solid #e8e8e8 !important;
}
</style>

View File

@@ -0,0 +1,286 @@
<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 { 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 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 fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_CATEGORY_Y002",
};
const result = await bizItemInfoListAll(reqParams);
const validData = (result || []).filter(item => item.xaxis && item.index01);
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',
top: '50%',
text: '暂无收入数据',
textStyle: { fontSize: 18, color: '#333' }
},
tooltip: { trigger: 'item' },
padding: [0, 0, 0, 0]
}, true);
return;
}
const pieData = rawData.value.map(item => ({
name: item.xaxis || '',
value: toTenThousandYuan(item.index01)
})).filter(item => item.value > 0);
const option = {
padding: [0, 0, 0, 0],
tooltip: {
trigger: 'item',
textStyle: { fontSize: 12 },
padding: 12,
backgroundColor: '#fff',
borderColor: '#e8e8e8',
borderWidth: 1,
formatter: (params: any) => `
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">${params.name}</div>
<div style="color: #666;">收入:${params.value.toFixed(2)} 万元</div>
`
},
legend: {
type: 'scroll',
orient: 'horizontal',
top: 5,
left: 'center',
textStyle: {
fontSize: 10,
color: '#333',
cursor: 'pointer'
},
itemGap: 15,
itemWidth: 12,
itemHeight: 12,
selectedMode: true,
scrollDataIndex: 0,
pageButtonPosition: 'end',
pageIconColor: '#1890ff',
pageIconInactiveColor: '#ccc',
pageTextStyle: { fontSize: 9 },
height: 30,
formatter: (name: string) => {
return name.length > 6 ? `${name.substring(0, 6)}...` : name;
}
},
series: [
{
name: '收入',
type: 'pie',
radius: ['30%', '60%'],
center: ['50%', '45%'],
data: pieData,
label: {
show: true,
position: 'outside',
fontSize: 10,
formatter: (params: any) => {
const shortName = params.name.length > 4 ? `${params.name.substring(0, 4)}...` : params.name;
return `${shortName}: ${params.value}万元`;
}
},
itemStyle: {
borderColor: '#fff',
borderWidth: 2
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
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);
padding: 0 !important;
margin: 0 !important;
height: 100%;
}
:deep(.ant-card-body) {
padding: 0 !important;
height: 100%;
}
:deep(.echarts-legend) {
display: block !important;
z-index: 999 !important;
padding: 0 0 0 5px !important;
margin: 0 !important;
text-align: center !important;
}
:deep(.echarts-legend-scroll) {
overflow: hidden !important;
white-space: nowrap !important;
height: 30px !important;
line-height: 30px !important;
}
:deep(.echarts-legend-item) {
display: inline-block !important;
margin-right: 15px !important;
white-space: nowrap !important;
cursor: pointer !important;
}
:deep(.echarts-tooltip) {
border-radius: 6px;
padding: 10px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
border: 1px solid #e8e8e8 !important;
}
</style>

View File

@@ -0,0 +1,459 @@
<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 myChart: 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 | number | undefined): number => {
const rawNum = formatNumber(num.toString());
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 formatMonth = (xaxis: string | undefined): string => {
if (!xaxis) return '';
const monthNum = parseInt(xaxis, 10);
return isNaN(monthNum) ? xaxis : `${monthNum}`;
};
const calculateMonthOnMonth = (current: string | undefined, last: string | undefined): number => {
const currentNum = formatNumber(current);
const lastNum = formatNumber(last);
if (lastNum === 0) return 0;
const mom = ((currentNum - lastNum) / lastNum) * 100;
return formatNumber(mom.toString(), 2);
};
const calculateNetProfitForChart = (income: string | undefined, expense: string | undefined): number => {
const profitInYuan = formatNumber((formatNumber(income) - formatNumber(expense)).toString());
return toTenThousandYuan(profitInYuan);
};
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 padding = Math.max((max - min) * 0.1, 1);
let minExtent = Math.min(min - padding, 0);
let maxExtent = max + padding;
return [minExtent, maxExtent];
};
const calculatePercentExtent = (data: number[]) => {
if (data.length === 0) return [-20, 100];
const min = Math.min(...data);
const max = Math.max(...data);
const padding = Math.max((max - min) * 0.15, 10);
return [min - padding, max + padding];
};
const fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_YEARDATA_M001",
};
const result = await bizItemInfoListAll(reqParams);
const validData = (result || []).filter(item => item.xaxis);
rawData.value = sortByMonth(validData);
} catch (error) {
console.error('获取数据失败:', error);
createMessage.error('获取月度环比数据失败,请稍后重试');
rawData.value = [];
}
};
const initChart = () => {
if (!chartDom.value) return;
if (!myChart) {
myChart = echarts.init(chartDom.value);
}
if (rawData.value.length === 0) {
myChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['本月收入','上月收入','本月支出','上月支出','收入环比','支出环比','净收益'], top: 10 },
grid: { left:10, right:40, bottom:60, top:40, containLabel:true },
xAxis: { type: 'category', data: [] },
yAxis: [
{ type: 'value', name: '万元' },
{ type: 'value', name: '%', position: 'right' }
],
series: [],
noDataLoadingOption: {
text: '暂无月度环比数据',
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
position: 'center'
}
}, true);
return;
}
const xAxisData = rawData.value.map(item => formatMonth(item.xaxis));
const incomeData = rawData.value.map(item => toTenThousandYuan(item.index01));
const lastIncomeData = rawData.value.map(item => toTenThousandYuan(item.index04));
const expenseData = rawData.value.map(item => toTenThousandYuan(item.index02));
const lastExpenseData = rawData.value.map(item => toTenThousandYuan(item.index05));
const incomeMomData = rawData.value.map(item => calculateMonthOnMonth(item.index01, item.index04));
const expenseMomData = rawData.value.map(item => calculateMonthOnMonth(item.index02, item.index05));
const netProfitData = rawData.value.map(item => calculateNetProfitForChart(item.index01, item.index02));
const amountData = [...incomeData, ...lastIncomeData, ...expenseData, ...lastExpenseData, ...netProfitData];
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
const [pMin, pMax] = calculatePercentExtent([...incomeMomData, ...expenseMomData]);
const profitBarLabelConfig = {
show: true,
position: 'top',
textStyle: { fontSize: 9, fontWeight: 'bold' },
formatter: (v: any) => formatWithThousandsSeparator(v.value)
};
const momLineStyle = {
smooth: true,
symbol: 'circle',
symbolSize: 5,
lineStyle: { width: 1.8 },
emphasis: {
symbolSize: 8,
lineStyle: { width: 2.2 }
}
};
const option = {
tooltip: {
trigger: 'axis',
padding: 12,
backgroundColor: '#fff',
borderColor: '#eee',
borderWidth: 1,
textStyle: { fontSize: 12 },
formatter: (params: any[]) => {
const month = params[0]?.axisValue;
const item = rawData.value.find(i => formatMonth(i.xaxis) === month);
if (!item) return '无数据';
const incomeMom = calculateMonthOnMonth(item.index01, item.index04);
const expenseMom = calculateMonthOnMonth(item.index02, item.index05);
const netProfitInYuan = formatNumber(item.index01) - formatNumber(item.index02);
let netProfitText = '';
let netProfitColor = '';
if (netProfitInYuan > 0) {
netProfitText = `<span style="font-weight:bold;">↑ ${formatWithThousandsSeparator(netProfitInYuan)}</span>`;
netProfitColor = '#52c41a';
} else if (netProfitInYuan < 0) {
netProfitText = `<span style="font-weight:bold;">↓ ${formatWithThousandsSeparator(Math.abs(netProfitInYuan))}</span>`;
netProfitColor = '#f5222d';
} else {
netProfitText = '<span style="font-weight:bold;">— 收支平衡</span>';
netProfitColor = '#666';
}
const incomeMomText = incomeMom >= 0
? `<span style="color:#f5222d; font-weight:bold;">↑ ${incomeMom.toFixed(2)}</span>`
: `<span style="color:#1890ff; font-weight:bold;">↓ ${Math.abs(incomeMom).toFixed(2)}</span>`;
const expenseMomText = expenseMom >= 0
? `<span style="color:#f5222d; font-weight:bold;">↑ ${expenseMom.toFixed(2)}</span>`
: `<span style="color:#1890ff; font-weight:bold;">↓ ${Math.abs(expenseMom).toFixed(2)}</span>`;
return `
<div style="text-align:center; font-weight:600; margin-bottom:8px;">${month}</div>
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr style="background:#f7f8fa;">
<th style="border:1px solid #eee; padding:6px; text-align:center;">本月收入(元)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">上月收入(元)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">收入环比(%)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">本月支出(元)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">上月支出(元)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">支出环比(%)</th>
<th style="border:1px solid #eee; padding:6px; text-align:center;">净收益(元)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border:1px solid #eee; padding:6px; color:#1890ff; text-align:right;">${formatWithThousandsSeparator(item.index01)}</td>
<td style="border:1px solid #eee; padding:6px; color:#ff7875; text-align:right;">${formatWithThousandsSeparator(item.index04)}</td>
<td style="border:1px solid #eee; padding:6px; text-align:center;">${incomeMomText}</td>
<td style="border:1px solid #eee; padding:6px; color:#f5222d; text-align:right;">${formatWithThousandsSeparator(item.index02)}</td>
<td style="border:1px solid #eee; padding:6px; color:#ffc53d; text-align:right;">${formatWithThousandsSeparator(item.index05)}</td>
<td style="border:1px solid #eee; padding:6px; text-align:center;">${expenseMomText}</td>
<td style="border:1px solid #eee; padding:6px; color:${netProfitColor}; text-align:center;">${netProfitText}</td>
</tr>
</tbody>
</table>
`;
}
},
legend: {
data: ['本月收入', '上月收入', '本月支出', '上月支出', '收入环比', '支出环比', '净收益'],
top: 10, left: 'center', textStyle: { fontSize: 11 }
},
grid: { left: 10, right: 40, bottom: 60, top: 40, containLabel: true },
xAxis: {
type: 'category',
data: xAxisData,
axisLabel: { fontSize: 10, rotate: 60 }
},
yAxis: [
{
type: 'value',
name: '万元',
min: amountMin, max: amountMax,
axisLabel: {
formatter: (v: any) => formatWithThousandsSeparator(v),
fontSize: 10
},
splitLine: { lineStyle: { type: 'dashed', opacity: 0.3 } }
},
{
type: 'value', name: '%', position: 'right',
min: pMin, max: pMax,
axisLabel: { formatter: (v: any) => v.toFixed(1) + '%' }
}
],
series: [
{
name: '上月收入',
type: 'line',
data: lastIncomeData,
yAxisIndex: 0,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { width: 1.5, color: '#ff7875' },
itemStyle: { color: '#ff7875' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(255,120,117,0.25)' },
{ offset: 1, color: 'rgba(255,120,117,0.02)' }
])
},
label: { show: false }
},
{
name: '本月收入',
type: 'line',
data: incomeData,
yAxisIndex: 0,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 1.5,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#1890ff' },
{ offset: 1, color: '#096dd9' }
])
},
itemStyle: { color: '#1890ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24,144,255,0.25)' },
{ offset: 1, color: 'rgba(24,144,255,0.02)' }
])
},
label: { show: false },
emphasis: { symbolSize: 8, lineStyle: { width: 2 } }
},
{
name: '上月支出',
type: 'line',
data: lastExpenseData,
yAxisIndex: 0,
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { width: 1.5, color: '#ffc53d' },
itemStyle: { color: '#ffc53d' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(255,197,61,0.2)' },
{ offset: 1, color: 'rgba(255,197,61,0.02)' }
])
},
label: { show: false }
},
{
name: '本月支出',
type: 'line',
data: expenseData,
yAxisIndex: 0,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 1.5,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f5222d' },
{ offset: 1, color: '#cf1322' }
])
},
itemStyle: { color: '#f5222d' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(245,34,45,0.25)' },
{ offset: 1, color: 'rgba(245,34,45,0.02)' }
])
},
label: { show: false },
emphasis: { symbolSize: 8, lineStyle: { width: 2 } }
},
{
name: '收入环比',
type: 'line',
data: incomeMomData,
yAxisIndex: 1,
...momLineStyle,
lineStyle: { ...momLineStyle.lineStyle, color: '#13c2c2' },
itemStyle: { color: '#13c2c2' },
label: { show: false }
},
{
name: '支出环比',
type: 'line',
data: expenseMomData,
yAxisIndex: 1,
...momLineStyle,
lineStyle: { ...momLineStyle.lineStyle, color: '#722ed1' },
itemStyle: { color: '#722ed1' },
label: { show: false }
},
{
name: '净收益',
type: 'bar',
data: netProfitData,
yAxisIndex: 0,
barWidth: 10,
barGap: '10%',
barCategoryGap: '20%',
itemStyle: {
color: (params: any) => params.data >= 0 ? '#52c41a' : '#f5222d',
borderRadius: [4, 4, 0, 0],
opacity: 0.85
},
label: profitBarLabelConfig,
emphasis: { itemStyle: { opacity: 1 } }
}
],
animationDuration: 500,
animationEasingUpdate: 'quinticInOut'
};
myChart.setOption(option, true);
};
const resizeChart = () => {
if (myChart) {
myChart.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 (myChart) {
myChart.dispose();
myChart = null;
}
});
</script>
<style scoped>
:deep(.ant-card) {
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
transition: all 0.3s ease;
}
: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;
box-shadow: 0 3px 10px rgba(0,0,0,0.15);
border: 1px solid #e8e8e8 !important;
}
:deep(.echarts-tooltip table) {
min-width: 600px;
}
:deep(.echarts-tooltip small) {
display: block;
font-size: 10px;
margin-top: 2px;
color: #999;
}
</style>

View File

@@ -0,0 +1,611 @@
<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 myChart: 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);
if (isNaN(parsed)) return 0;
return 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 formatPercentage = (num: string | undefined, decimal = 2): string => {
const parsed = formatNumber(num, decimal);
return `${parsed.toFixed(decimal)}%`;
};
// 计算净收益
const calculateNetProfit = (income: string | undefined, expense: string | undefined): number => {
const incomeNum = formatNumber(income);
const expenseNum = formatNumber(expense);
return formatNumber((incomeNum - expenseNum).toString());
};
// 按月份排序
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 padding = Math.max((max - min) * 0.1, 1);
let minExtent = min - padding;
let maxExtent = max + padding;
minExtent = Math.max(minExtent, 0);
maxExtent = Math.max(maxExtent, 0);
return [minExtent, maxExtent];
};
// 计算百分比轴的范围
const calculatePercentYAxisExtent = (data: number[]) => {
if (data.length === 0) return [0, 100];
const min = Math.min(...data);
const max = Math.max(...data);
const actualMin = Math.min(Math.max(min, 0), 100);
const actualMax = Math.max(max, 0);
const padding = Math.max((actualMax - actualMin) * 0.1, 5);
const maxExtent = Math.ceil(actualMax + padding);
return [0, maxExtent > 0 ? maxExtent : 10];
};
// 获取数据列表
const fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_YEARQUAR_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);
createMessage.error('获取收支趋势数据失败,请稍后重试'); // 增加友好提示
rawData.value = [];
}
};
// 初始化/更新图表(核心优化)
const initChart = () => {
// 确保DOM元素存在
if (!chartDom.value) return;
// 懒初始化图表实例,避免重复销毁创建
if (!myChart) {
myChart = echarts.init(chartDom.value);
}
// 无数据时的配置
if (rawData.value.length === 0) {
myChart.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: 40,
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
},
{
type: 'value',
name: '百分比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(1)}%`
},
splitLine: { show: false },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: false,
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 proportionData = rawData.value.map(item => formatNumber(item.index03));
const amountData = [...incomeData, ...expenseData];
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
const [percentMin, percentMax] = calculatePercentYAxisExtent(proportionData);
// 标签配置
const barLabelConfig = {
show: true,
position: 'top',
distance: 3,
textStyle: {
fontSize: 10,
color: '#333',
fontWeight: '500'
},
formatter: (params: any) => `${params.value.toFixed(2)}`
};
const lineLabelConfig = {
show: true,
position: 'top',
distance: 5,
textStyle: {
fontSize: 9,
fontWeight: '500',
backgroundColor: 'rgba(255,255,255,0.8)',
padding: [2, 4],
borderRadius: 2
},
formatter: (params: any) => `${params.value.toFixed(1)}%`
};
// 图表主配置
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 currentQuarter = params[0]?.axisValue || '';
const item = rawData.value.find(i => i.xaxis === currentQuarter);
if (!item) return `<div style="padding: 8px;">${currentQuarter}:暂无明细</div>`;
const netProfit = calculateNetProfit(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;">${currentQuarter}</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: #722ed1;">${formatPercentage(item.index03)}</td>
</tr>
</tbody>
</table>
`;
}
},
legend: {
data: ['收入', '支出', '支出占比'],
top: 10,
left: 'center',
textStyle: { fontSize: 11 }
},
grid: {
left: 10,
right: 40,
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
},
{
type: 'value',
name: '百分比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(1)}%`
},
min: percentMin,
max: percentMax,
axisLine: { onZero: true },
splitLine: { show: false },
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: false,
position: 'right',
boundaryGap: [0, '10%']
}
],
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: 1, color: '#f5222d' }
])
}
}
},
{
name: '支出占比',
type: 'line',
data: proportionData,
yAxisIndex: 1,
symbol: 'circle',
symbolSize: 6,
smooth: true,
lineStyle: {
width: 1,
color: '#722ed1',
type: 'solid'
},
itemStyle: {
color: '#722ed1',
borderColor: '#fff',
borderWidth: 1
},
label: lineLabelConfig,
emphasis: {
symbolSize: 8,
lineStyle: {
width: 2
}
}
}
],
animationDuration: 500,
animationEasingUpdate: 'quinticInOut'
};
// 关键第二个参数设为true强制更新配置
myChart.setOption(option, true);
};
// 调整图表大小(防抖)
const resizeChart = () => {
if (myChart) {
myChart.resize({
animation: {
duration: 300,
easing: 'quadraticInOut'
}
});
}
};
const debounceResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resizeChart, 100);
};
// 监听formParams变化自动重新获取数据
watch(
() => props.formParams,
(newParams) => {
if (Object.keys(newParams).length) {
fetchDataList(newParams);
}
},
{ deep: true, immediate: true }
);
// 监听数据/DOM变化自动更新图表更高效
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 (myChart) {
myChart.dispose();
myChart = 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-tooltip table) {
width: 100%;
border-collapse: collapse;
}
: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;
}
:deep(.echarts-tooltip::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
:deep(.echarts-tooltip::-webkit-scrollbar-thumb) {
background: #d9d9d9;
border-radius: 3px;
}
:deep(.echarts-tooltip::-webkit-scrollbar-track) {
background: #f5f5f5;
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,380 @@
<template>
<Card title="年度支出排名及占比" style="width: 100%; height: 100%;">
<div ref="chartDom" style="width: 100%; height: calc(100% - 40px);"></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): number => {
if (!num) return 0;
const parsed = Number(num);
return isNaN(parsed) ? 0 : parsed;
};
// 格式化金额显示
const formatMoney = (num: number): string => {
return num.toLocaleString('zh-CN');
};
// 转换为万元单位
const toTenThousandYuan = (num: number): number => {
return Number((num / 10000).toFixed(2));
};
// 按序号排序
const sortBySeq = (data: BizItemInfo[]): BizItemInfo[] => {
return data.sort((a, b) => {
const seqA = a.seq ? parseInt(a.seq, 10) : 999;
const seqB = b.seq ? parseInt(b.seq, 10) : 999;
return seqA - seqB;
});
};
// 获取数据列表
const fetchDataList = async (params?: Record<string, any>) => {
try {
const reqParams = {
...(params || props.formParams),
itemCode: "ERP_YEARRANK_Y001"
};
const result = await bizItemInfoListAll(reqParams);
const validData = (result || []).filter(item => item.xaxis && item.seq && item.index01);
const sortedData = sortBySeq(validData);
rawData.value = sortedData.slice(0, 10);
} catch (error) {
console.error('获取数据列表失败:', error);
rawData.value = [];
}
};
// 获取排名对应的渐变颜色
const getRankColorGradient = (rank: number) => {
if (rank === 1) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f5222d' },
{ offset: 0.5, color: '#ff4d4f' },
{ offset: 1, color: '#ff7875' }
]);
} else if (rank === 2) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#fa8c16' },
{ offset: 0.5, color: '#ffa940' },
{ offset: 1, color: '#ffc069' }
]);
} else if (rank === 3) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#fadb14' },
{ offset: 0.5, color: '#ffec3d' },
{ offset: 1, color: '#fff566' }
]);
} else {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#1890ff' },
{ offset: 0.5, color: '#40a9ff' },
{ offset: 1, color: '#69c0ff' }
]);
}
};
// 获取hover时的强调色
const getRankEmphasisColor = (rank: number) => {
if (rank === 1) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#cf1322' },
{ offset: 1, color: '#f5222d' }
]);
} else if (rank === 2) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#d46b08' },
{ offset: 1, color: '#fa8c16' }
]);
} else if (rank === 3) {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#d4b106' },
{ offset: 1, color: '#fadb14' }
]);
} else {
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#096dd9' },
{ offset: 1, color: '#1890ff' }
]);
}
};
// 初始化/更新图表
const initChart = () => {
// 确保DOM元素存在
if (!chartDom.value) return;
// 初始化图表实例(避免重复创建)
if (!chartInstance) {
chartInstance = echarts.init(chartDom.value);
}
// 无数据时显示提示
if (rawData.value.length === 0) {
chartInstance.setOption({
title: {
left: 'center',
top: '50%',
text: '暂无排名数据',
textStyle: { fontSize: 18, color: '#333' }
},
tooltip: { trigger: 'axis' },
padding: [2, 2, 2, 2]
}, true); // 第二个参数设为true强制更新
return;
}
// 处理图表数据
const categories = rawData.value.map(item => item.xaxis || '').reverse();
const rawAmountData = rawData.value.map(item => formatNumber(item.index01)).reverse();
const amountData = rawAmountData.map(num => toTenThousandYuan(num));
const ratioData = rawData.value.map(item => {
const ratio = formatNumber(item.index02);
return Number(ratio.toFixed(2));
}).reverse();
const rankData = rawData.value.map(item => parseInt(item.seq || '0', 10)).reverse();
// 图表配置项
const option = {
padding: [2, 2, 2, 2],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
textStyle: { fontSize: 12 },
padding: 12,
backgroundColor: '#fff',
borderColor: '#e8e8e8',
borderWidth: 1,
formatter: (params: any) => {
const index = params[0].dataIndex;
return `
<div style="font-weight: 600; color: #333; margin-bottom: 8px;">${categories[index]}</div>
<div style="color: #666; margin-bottom: 4px;">排名:${rankData[index]}名</div>
<div style="color: #666; margin-bottom: 4px;">支出:${formatMoney(rawAmountData[index])} 元</div>
<div style="color: #666;">占比:${ratioData[index]}%</div>
`;
}
},
grid: {
left: '2%',
right: '18%',
top: '2%',
bottom: '2%',
containLabel: true
},
xAxis: [
{
type: 'value',
name: '支出金额 (万元)',
nameTextStyle: { fontSize: 11, color: '#666' },
axisLabel: {
fontSize: 10,
color: '#666',
formatter: (value: number) => value.toFixed(1)
},
axisLine: { lineStyle: { color: '#e8e8e8' } },
splitLine: { lineStyle: { color: '#f5f5f5' } }
}
],
yAxis: [
{
type: 'category',
data: categories,
axisLabel: {
fontSize: 11,
color: '#333',
formatter: (name: string, index: number) => {
const shortName = name.length > 8 ? `${name.substring(0, 8)}...` : name;
return `${rankData[index]} ${shortName}`;
}
},
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
color: ['#fafafa', '#fff'],
type: 'solid'
}
}
}
],
series: [
{
name: '支出金额',
type: 'bar',
data: amountData,
barWidth: 12,
itemStyle: {
borderRadius: [0, 4, 4, 0],
color: (params: any) => getRankColorGradient(rankData[params.dataIndex])
},
label: {
show: true,
position: 'right',
fontSize: 10,
color: '#666',
formatter: (params: any) => `${params.data}万元 (${ratioData[params.dataIndex]}%)`
},
emphasis: {
itemStyle: {
color: (params: any) => getRankEmphasisColor(rankData[params.dataIndex])
}
}
}
]
};
// 设置配置项强制更新关键第二个参数为true
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);
};
// 监听formParams变化重新获取数据
watch(
() => props.formParams,
(newParams) => {
if (Object.keys(newParams).length) {
fetchDataList(newParams);
}
},
{ deep: true, immediate: true }
);
// 监听数据变化更新图表使用watchEffect更高效
watchEffect(() => {
// 当rawData或chartDom变化时重新初始化图表
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);
padding: 0 !important;
margin: 0 !important;
height: 100%;
}
:deep(.ant-card-body) {
padding: 16px !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;
}
:deep(.echarts-bar) {
border-radius: 4px;
}
:deep(.echarts-axis-line) {
stroke: #e8e8e8;
}
:deep(.echarts-text) {
fill: #666;
}
</style>

View File

@@ -0,0 +1,587 @@
<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 } 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 rawData = ref<BizItemInfo[]>([]);
const chartDom = ref<HTMLDivElement | null>(null);
let myChart: echarts.ECharts | null = null;
const { createMessage } = useMessage();
const formatNumber = (num: string | undefined, decimal = 2): number => {
if (!num) return 0;
const parsed = Number(num);
if (isNaN(parsed)) return 0;
return 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 formatPercentage = (num: string | undefined, decimal = 2): string => {
const parsed = formatNumber(num, decimal);
return `${parsed.toFixed(decimal)}%`;
};
const calculateNetProfit = (income: string | undefined, expense: string | undefined): number => {
const incomeNum = formatNumber(income);
const expenseNum = formatNumber(expense);
return formatNumber((incomeNum - expenseNum).toString());
};
const formatYear = (xaxis: string | undefined): string => {
if (!xaxis) return '';
const yearNum = parseInt(xaxis, 10);
return isNaN(yearNum) ? xaxis : `${yearNum}`;
};
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 padding = Math.max((max - min) * 0.1, 1);
let minExtent = min - padding;
let maxExtent = max + padding;
if (minExtent > 0) minExtent = 0;
if (maxExtent < 0) maxExtent = 0;
return [minExtent, maxExtent];
};
// 新增计算百分比Y轴范围
const calculatePercentYAxisExtent = (data: number[]) => {
if (data.length === 0) return [0, 100];
const min = Math.min(...data);
const max = Math.max(...data);
// 百分比数据的padding调整为5%
const padding = Math.max(Math.abs(max - min) * 0.1, 5);
let minExtent = Math.floor(min - padding);
let maxExtent = Math.ceil(max + padding);
// 确保最小值不小于0百分比通常非负
if (minExtent < 0) minExtent = 0;
return [minExtent, maxExtent];
};
const fetchList = async () => {
try {
const params = {
itemCode: "ERP_YEARPMOM_M001"
};
const result = await bizItemInfoListAll(params);
const validData = (result || [])
.filter(item => item.xaxis)
.filter(item => item.index01 || item.index02);
rawData.value = sortByMonth(validData);
} catch (error) {
console.error('获取数据列表失败:', error);
rawData.value = [];
} finally {
initChart();
}
};
const initChart = () => {
if (!chartDom.value) return;
if (myChart) {
myChart.dispose();
}
myChart = echarts.init(chartDom.value);
if (rawData.value.length === 0) {
myChart.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: 40, // 增加右侧边距给百分比Y轴
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: true
},
{
type: 'value',
name: '百分比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(1)}%`
},
splitLine: { show: false }, // 不显示百分比轴的分割线
axisTick: { alignWithLabel: true },
splitNumber: 5,
scale: true,
position: 'right', // 百分比轴显示在右侧
max: 100 // 百分比最大100
}
],
series: [],
noDataLoadingOption: {
text: '暂无收入支出数据',
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
position: 'center',
effect: 'none'
}
});
return;
}
const xAxisData = rawData.value.map(item => formatYear(item.xaxis));
const incomeData = rawData.value.map(item => toTenThousandYuan(item.index01));
const expenseData = rawData.value.map(item => toTenThousandYuan(item.index02));
// 移除环比数据,只保留占比数据
const proportionData = rawData.value.map(item => formatNumber(item.index04));
const amountData = [...incomeData, ...expenseData];
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
// 只计算占比的百分比轴范围
const [percentMin, percentMax] = calculatePercentYAxisExtent(proportionData);
const barLabelConfig = {
show: true,
position: 'top',
distance: 3,
textStyle: {
fontSize: 10,
color: '#333',
fontWeight: '500'
},
formatter: (params: any) => `${params.value.toFixed(2)}`
};
// 新增:折线图标签配置
const lineLabelConfig = {
show: true,
position: 'top',
distance: 5,
textStyle: {
fontSize: 9,
fontWeight: '500',
backgroundColor: 'rgba(255,255,255,0.8)',
padding: [2, 4],
borderRadius: 2
},
formatter: (params: any) => `${params.value.toFixed(1)}%`
};
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 currentMonth = params[0]?.axisValue || '';
const item = rawData.value.find(i => formatYear(i.xaxis) === currentMonth);
if (!item) return `<div style="padding: 8px;">${currentMonth}:暂无明细</div>`;
const netProfit = calculateNetProfit(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;">${currentMonth}</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: #722ed1;">${formatPercentage(item.index04)}</td>
</tr>
</tbody>
</table>
`;
}
},
legend: {
// 移除环比,只保留收入、支出、占比
data: ['收入', '支出', '占比'],
top: 10,
left: 'center',
textStyle: { fontSize: 11 }
},
grid: {
left: 10,
right: 40, // 增加右侧边距给百分比Y轴
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: true
},
{
type: 'value',
name: '百分比(%',
nameTextStyle: { fontSize: 11 },
axisLabel: {
fontSize: 10,
formatter: (value: number) => `${value.toFixed(1)}%`
},
min: percentMin,
max: percentMax,
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: 1, color: '#f5222d' }
])
}
}
},
// 只保留占比曲线,移除环比曲线
{
name: '占比',
type: 'line',
data: proportionData,
yAxisIndex: 1, // 使用右侧的百分比Y轴
symbol: 'circle',
symbolSize: 6,
smooth: true, // 平滑曲线
lineStyle: {
width: 1,
color: '#722ed1', // 紫色
type: 'solid'
},
itemStyle: {
color: '#722ed1',
borderColor: '#fff',
borderWidth: 1
},
label: lineLabelConfig,
emphasis: {
symbolSize: 8,
lineStyle: {
width: 2
}
}
}
],
animationDuration: 500,
animationEasingUpdate: 'quinticInOut'
};
myChart.setOption(option);
};
const resizeChart = () => {
if (myChart) {
myChart.resize({
animation: {
duration: 300,
easing: 'quadraticInOut'
}
});
}
};
let resizeTimer: number;
const debounceResize = () => {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resizeChart, 100);
};
onMounted(async () => {
await fetchList();
window.addEventListener('resize', debounceResize);
});
onUnmounted(() => {
window.removeEventListener('resize', debounceResize);
if (myChart) {
myChart.dispose();
myChart = null;
}
});
watch(rawData, () => {
initChart();
}, { deep: true });
</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: 500px; /* 加宽tooltip以容纳新增列 */
font-family: 'Microsoft YaHei', sans-serif;
}
:deep(.echarts-tooltip table) {
width: 100%;
border-collapse: collapse;
}
: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;
}
:deep(.echarts-tooltip::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
:deep(.echarts-tooltip::-webkit-scrollbar-thumb) {
background: #d9d9d9;
border-radius: 3px;
}
:deep(.echarts-tooltip::-webkit-scrollbar-track) {
background: #f5f5f5;
border-radius: 3px;
}
</style>