财务门户设计
This commit is contained in:
@@ -1,517 +0,0 @@
|
||||
<template>
|
||||
<Card title="账户分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { BasicForm, FormSchema } from '@jeesite/core/components/Form';
|
||||
import { erpExpIncListAll, ErpExpInc } from '@jeesite/erp/api/erp/expInc';
|
||||
import * as echarts from 'echarts';
|
||||
import type {
|
||||
ECharts,
|
||||
EChartsOption,
|
||||
SeriesOption,
|
||||
BarSeriesOption
|
||||
} from 'echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
|
||||
// 图表DOM引用
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
// 图表实例
|
||||
let myChart: ECharts | null = null;
|
||||
// 数据列表
|
||||
const tableData = ref<ErpExpInc[]>([]);
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 单位转换:元转万元(除以10000)
|
||||
*/
|
||||
const convertToTenThousand = (num: number): number => {
|
||||
if (isNaN(num) || num === null || num === undefined) return 0;
|
||||
return num / 10000;
|
||||
};
|
||||
|
||||
/**
|
||||
* 单位转换:万元转元(乘以10000)- 用于Tooltip显示
|
||||
*/
|
||||
const convertToYuan = (num: number): number => {
|
||||
if (isNaN(num) || num === null || num === undefined) return 0;
|
||||
return num * 10000;
|
||||
};
|
||||
|
||||
/**
|
||||
* 日期排序工具:支持2025-Q1、2024-12等格式排序
|
||||
* @param dates 日期数组
|
||||
* @returns 排序后的日期数组(升序)
|
||||
*/
|
||||
const sortDates = (dates: string[]): string[] => {
|
||||
return dates.sort((a, b) => {
|
||||
// 解析日期格式:2025-Q1 → [2025, 1];2024-12 → [2024, 12]
|
||||
const parseDate = (dateStr: string) => {
|
||||
const [year, part] = dateStr.split(/[-Q]/);
|
||||
const quarterOrMonth = part ? parseInt(part) : 1;
|
||||
return { year: parseInt(year), quarterOrMonth };
|
||||
};
|
||||
|
||||
const dateA = parseDate(a);
|
||||
const dateB = parseDate(b);
|
||||
|
||||
// 先比较年份
|
||||
if (dateA.year !== dateB.year) {
|
||||
return dateA.year - dateB.year;
|
||||
}
|
||||
// 年份相同比较季度/月份
|
||||
return dateA.quarterOrMonth - dateB.quarterOrMonth;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据列表 - 增加类型约束和加载状态
|
||||
*/
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
if (loading.value) return;
|
||||
try {
|
||||
const result = await erpExpIncListAll(params);
|
||||
tableData.value = result || [];
|
||||
initChart();
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
tableData.value = [];
|
||||
if (myChart) {
|
||||
myChart.clear();
|
||||
showEmptyChart();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数:保留2位小数(处理0值和整数,统一格式)
|
||||
*/
|
||||
const formatNumber = (num: number): string => {
|
||||
if (isNaN(num) || num === null || num === undefined) return '0.00';
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 基础柱状图配置 - 增加圆角效果
|
||||
*/
|
||||
const baseBarConfig: BarSeriesOption = {
|
||||
type: 'bar',
|
||||
barWidth: 15, // 加宽柱子,适配总值显示
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 10,
|
||||
color: '#333',
|
||||
formatter: function(params: any) {
|
||||
// 多层校验,确保数值安全
|
||||
let value = 0;
|
||||
if (params && params.value !== undefined && params.value !== null) {
|
||||
value = typeof params.value === 'number' ? params.value : Number(params.value);
|
||||
}
|
||||
// 只有正数才显示标签,显示万元数值
|
||||
return value > 0 ? formatNumber(value) : '';
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderType: 'solid',
|
||||
// 圆角配置:四个角都设置圆角,数值越大越圆润(建议值:8-15,根据barWidth调整)
|
||||
borderRadius: [8, 8, 0, 0], // 上左、上右、下右、下左(只给顶部圆角,更符合视觉习惯)
|
||||
// 可选:添加轻微阴影增强立体感
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
legendHoverLink: false,
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut'
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示空数据图表
|
||||
*/
|
||||
const showEmptyChart = () => {
|
||||
if (!myChart) return;
|
||||
|
||||
const emptyOption: EChartsOption = {
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { fontSize: 16, color: '#999' }
|
||||
},
|
||||
xAxis: { type: 'category', data: [] },
|
||||
yAxis: { type: 'value', name: '金额(万元)' },
|
||||
series: [],
|
||||
tooltip: { trigger: 'none' }
|
||||
};
|
||||
|
||||
myChart.setOption(emptyOption);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理数据表,生成ECharts所需结构(X轴为银行名称,展示最新周期数据)
|
||||
*/
|
||||
const processTableData = () => {
|
||||
// 过滤有效数据
|
||||
const validData = tableData.value.filter(item =>
|
||||
item?.statDate && item?.accountName && (item.incomeAmount || item.expenseAmount)
|
||||
);
|
||||
|
||||
if (validData.length === 0) {
|
||||
return { accountNames: [], series: [], bankDetailMap: {}, latestDate: '', sortedDates: [] };
|
||||
}
|
||||
|
||||
// 1. 获取所有日期并排序(支持季度/月份格式)
|
||||
const allDates = Array.from(new Set(validData.map(item => item.statDate!)));
|
||||
const sortedDates = sortDates(allDates);
|
||||
// 最新周期
|
||||
const latestDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
// 2. 按银行分组,汇总最新周期数据 + 保存所有周期明细
|
||||
const bankDetailMap: Record<string, Record<string, { income: number; expense: number }>> = {};
|
||||
const latestIncomeMap: Record<string, number> = {};
|
||||
const latestExpenseMap: Record<string, number> = {};
|
||||
|
||||
validData.forEach(item => {
|
||||
const accountName = item.accountName!;
|
||||
const statDate = item.statDate!;
|
||||
const incomeAmount = convertToTenThousand(Number(item.incomeAmount) || 0);
|
||||
const expenseAmount = convertToTenThousand(Number(item.expenseAmount) || 0);
|
||||
|
||||
// 初始化银行明细
|
||||
if (!bankDetailMap[accountName]) {
|
||||
bankDetailMap[accountName] = {};
|
||||
latestIncomeMap[accountName] = 0;
|
||||
latestExpenseMap[accountName] = 0;
|
||||
}
|
||||
|
||||
// 保存该周期明细
|
||||
bankDetailMap[accountName][statDate] = { income: incomeAmount, expense: expenseAmount };
|
||||
|
||||
// 累加最新周期数据
|
||||
if (statDate === latestDate) {
|
||||
latestIncomeMap[accountName] += incomeAmount;
|
||||
latestExpenseMap[accountName] += expenseAmount;
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 获取所有银行名称
|
||||
const accountNames = Array.from(new Set(Object.keys(bankDetailMap))).filter(Boolean);
|
||||
|
||||
// 4. 生成系列数据(收入和支出两个系列,展示最新周期数据)
|
||||
const incomeSeries: BarSeriesOption = {
|
||||
...baseBarConfig,
|
||||
name: '收入',
|
||||
data: accountNames.map(name => latestIncomeMap[name] || 0),
|
||||
itemStyle: {
|
||||
...baseBarConfig.itemStyle,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#73d13d' },
|
||||
{ offset: 1, color: '#52c41a' }
|
||||
])
|
||||
},
|
||||
label: {
|
||||
...baseBarConfig.label,
|
||||
color: '#52c41a'
|
||||
}
|
||||
};
|
||||
|
||||
const expenseSeries: BarSeriesOption = {
|
||||
...baseBarConfig,
|
||||
name: '支出',
|
||||
data: accountNames.map(name => latestExpenseMap[name] || 0),
|
||||
itemStyle: {
|
||||
...baseBarConfig.itemStyle,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#ff4d4f' },
|
||||
{ offset: 1, color: '#f5222d' }
|
||||
])
|
||||
},
|
||||
label: {
|
||||
...baseBarConfig.label,
|
||||
color: '#f5222d'
|
||||
}
|
||||
};
|
||||
|
||||
const series: SeriesOption[] = [incomeSeries, expenseSeries];
|
||||
return {
|
||||
accountNames,
|
||||
series,
|
||||
bankDetailMap,
|
||||
latestDate,
|
||||
sortedDates
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化图表(X轴为银行名称,Tooltip改为表格形式展示)
|
||||
*/
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
// 销毁旧实例
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新实例
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
const { accountNames, series, bankDetailMap, latestDate, sortedDates } = processTableData();
|
||||
|
||||
// 空数据处理
|
||||
if (accountNames.length === 0) {
|
||||
showEmptyChart();
|
||||
return;
|
||||
}
|
||||
|
||||
const option: EChartsOption = {
|
||||
title: {
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16, color: '#333' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontSize: 12 },
|
||||
padding: [12, 15],
|
||||
// Tooltip重构为:标题 + 明细表格 + 汇总行
|
||||
formatter: function(params: any) {
|
||||
if (!params || params.length === 0) return '';
|
||||
// 获取当前银行名称
|
||||
const bankName = params[0]?.axisValue || '';
|
||||
const bankDetails = bankDetailMap[bankName] || {};
|
||||
|
||||
// 生成周期明细表格行
|
||||
let cycleTableRows = '';
|
||||
let hasData = false;
|
||||
sortedDates.forEach(date => {
|
||||
const detail = bankDetails[date] || { income: 0, expense: 0 };
|
||||
const incomeYuan = convertToYuan(detail.income);
|
||||
const expenseYuan = convertToYuan(detail.expense);
|
||||
|
||||
if (incomeYuan > 0 || expenseYuan > 0) {
|
||||
hasData = true;
|
||||
cycleTableRows += `
|
||||
<tr>
|
||||
<td style="padding: 4px 8px; text-align: left;">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; background: ${incomeYuan > 0 ? '#52c41a' : '#f5222d'}; border-radius: 2px; margin-right: 6px;"></span>
|
||||
${date}
|
||||
</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #52c41a;">${formatNumber(incomeYuan)}</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #f5222d;">${formatNumber(expenseYuan)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// 空明细处理
|
||||
if (!hasData) {
|
||||
cycleTableRows = `
|
||||
<tr>
|
||||
<td colspan="3" style="padding: 8px; text-align: center; color: #999;">暂无周期明细数据</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// 计算最新周期汇总
|
||||
const latestDetail = bankDetails[latestDate] || { income: 0, expense: 0 };
|
||||
const latestIncome = convertToYuan(latestDetail.income);
|
||||
const latestExpense = convertToYuan(latestDetail.expense);
|
||||
const netIncome = latestIncome - latestExpense;
|
||||
|
||||
// 完整表格化Tooltip结构
|
||||
return `
|
||||
<!-- 标题 -->
|
||||
<div style="font-weight: 600; margin-bottom: 8px; text-align: center; font-size: 14px;">${bankName}</div>
|
||||
<!-- 明细表格 -->
|
||||
<table style="width: 100%; border-collapse: collapse; border: 1px solid #eee; margin-bottom: 8px;">
|
||||
<thead>
|
||||
<tr style="background: #f8f8f8;">
|
||||
<th style="padding: 6px 8px; text-align: left; border: 1px solid #eee;">周期</th>
|
||||
<th style="padding: 6px 8px; text-align: right; border: 1px solid #eee; color: #52c41a;">收入(元)</th>
|
||||
<th style="padding: 6px 8px; text-align: right; border: 1px solid #eee; color: #f5222d;">支出(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${cycleTableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 汇总行 -->
|
||||
<div style="border-top: 1px solid #eee; margin: 8px 0; padding-top: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; color: #333;">
|
||||
<span style="color: #52c41a;">本期总收入:${formatNumber(latestIncome)} 元</span>
|
||||
<span style="color: #f5222d;">本期总支出:${formatNumber(latestExpense)} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: left; font-weight: 600; color: #333;">
|
||||
本期净收入:${formatNumber(netIncome)} 元
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
orient: 'horizontal',
|
||||
textStyle: { fontSize: 12, color: '#333' },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 20
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: accountNames,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
rotate: 45, // 银行名称旋转15度,避免重叠
|
||||
interval: 0
|
||||
},
|
||||
axisTick: { inside: true },
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
formatter: (value: any) => `${formatNumber(Number(value))} 万元`
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
max: (value: { max: number }) => value.max > 0 ? value.max * 1.15 : 0.1
|
||||
},
|
||||
series: series,
|
||||
responsive: true,
|
||||
barGap: '20%',
|
||||
barCategoryGap: '30%'
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
} catch (error) {
|
||||
console.error('初始化图表失败:', error);
|
||||
showEmptyChart();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 窗口缩放自适应
|
||||
*/
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
clearTimeout((window as any).chartResizeTimer);
|
||||
(window as any).chartResizeTimer = setTimeout(() => {
|
||||
myChart?.resize();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听数据变化自动更新图表
|
||||
watch(tableData, () => {
|
||||
if (!loading.value) {
|
||||
initChart();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchList(props.formParams);
|
||||
window.addEventListener('resize', resizeChart);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeChart);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
clearTimeout((window as any).chartResizeTimer);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams); // 参数变化时重新调用fetchList
|
||||
},
|
||||
{ deep: true, immediate: false } // deep: true 监听对象内部变化
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 图例颜色 */
|
||||
:deep(.echarts-legend-item:nth-child(1) .echarts-legend-symbol) {
|
||||
background-color: #52c41a !important;
|
||||
border-radius: 4px; /* 图例也添加圆角,保持风格统一 */
|
||||
}
|
||||
:deep(.echarts-legend-item:nth-child(2) .echarts-legend-symbol) {
|
||||
background-color: #f5222d !important;
|
||||
border-radius: 4px; /* 图例也添加圆角,保持风格统一 */
|
||||
}
|
||||
|
||||
/* Tooltip样式优化(适配表格) */
|
||||
:deep(.echarts-tooltip) {
|
||||
border-radius: 8px;
|
||||
padding: 12px !important;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
border: none;
|
||||
background: #fff;
|
||||
z-index: 9999 !important;
|
||||
min-width: 450px; /* 保证表格有足够宽度 */
|
||||
max-width: 500px; /* 限制最大宽度避免过宽 */
|
||||
}
|
||||
|
||||
/* 优化标签显示效果 */
|
||||
:deep(.echarts-bar-label) {
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 1px rgba(255,255,255,0.8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优化x轴标签显示 */
|
||||
:deep(.echarts-xaxis-label) {
|
||||
word-break: keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tooltip表格样式增强 */
|
||||
:deep(.echarts-tooltip table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip th) {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -1,482 +0,0 @@
|
||||
<template>
|
||||
<Card title="周期分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { BasicForm, FormSchema } from '@jeesite/core/components/Form';
|
||||
import { erpExpIncListAll, ErpExpInc } from '@jeesite/erp/api/erp/expInc';
|
||||
import * as echarts from 'echarts';
|
||||
import type {
|
||||
ECharts,
|
||||
EChartsOption,
|
||||
SeriesOption,
|
||||
BarSeriesOption
|
||||
} from 'echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
// 图表DOM引用
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
// 图表实例
|
||||
let myChart: ECharts | null = null;
|
||||
// 数据列表
|
||||
const tableData = ref<ErpExpInc[]>([]);
|
||||
// 加载状态
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 单位转换:元转万元(除以10000)
|
||||
*/
|
||||
const convertToTenThousand = (num: number): number => {
|
||||
if (isNaN(num) || num === null || num === undefined) return 0;
|
||||
return num / 10000;
|
||||
};
|
||||
|
||||
/**
|
||||
* 单位转换:万元转元(乘以10000)- 用于Tooltip显示
|
||||
*/
|
||||
const convertToYuan = (num: number): number => {
|
||||
if (isNaN(num) || num === null || num === undefined) return 0;
|
||||
return num * 10000;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据列表 - 增加类型约束和加载状态
|
||||
*/
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
if (loading.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await erpExpIncListAll(params);
|
||||
tableData.value = result || [];
|
||||
initChart();
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
tableData.value = [];
|
||||
if (myChart) {
|
||||
myChart.clear();
|
||||
showEmptyChart();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具函数:保留2位小数(处理0值和整数,统一格式)
|
||||
*/
|
||||
const formatNumber = (num: number): string => {
|
||||
if (isNaN(num) || num === null || num === undefined) return '0.00';
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* 基础柱状图配置 - 增加圆角效果
|
||||
*/
|
||||
const baseBarConfig: BarSeriesOption = {
|
||||
type: 'bar',
|
||||
barWidth: 15, // 加宽柱子,适配总值显示
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 10,
|
||||
color: '#333',
|
||||
formatter: function(params: any) {
|
||||
// 多层校验,确保数值安全
|
||||
let value = 0;
|
||||
if (params && params.value !== undefined && params.value !== null) {
|
||||
value = typeof params.value === 'number' ? params.value : Number(params.value);
|
||||
}
|
||||
// 只有正数才显示标签,显示万元数值
|
||||
return value > 0 ? formatNumber(value) : '';
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
borderType: 'solid',
|
||||
// 圆角配置:四个角都设置圆角,数值越大越圆润(建议值:8-15,根据barWidth调整)
|
||||
borderRadius: [8, 8, 0, 0], // 上左、上右、下右、下左(只给顶部圆角,更符合视觉习惯)
|
||||
// 可选:添加轻微阴影增强立体感
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
legendHoverLink: false,
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut'
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示空数据图表
|
||||
*/
|
||||
const showEmptyChart = () => {
|
||||
if (!myChart) return;
|
||||
|
||||
const emptyOption: EChartsOption = {
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { fontSize: 16, color: '#999' }
|
||||
},
|
||||
xAxis: { type: 'category', data: [] },
|
||||
yAxis: { type: 'value', name: '金额(万元)' },
|
||||
series: [],
|
||||
tooltip: { trigger: 'none' }
|
||||
};
|
||||
|
||||
myChart.setOption(emptyOption);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理数据表,生成ECharts所需结构(改为总值模式)
|
||||
*/
|
||||
const processTableData = () => {
|
||||
// 过滤有效数据
|
||||
const validData = tableData.value.filter(item =>
|
||||
item?.statDate && (item.incomeAmount || item.expenseAmount)
|
||||
);
|
||||
|
||||
// 1. 提取日期并排序(升序:24年在前,25年在后)
|
||||
const dateSet = new Set(validData.map(item => item.statDate!));
|
||||
const dateList = Array.from(dateSet).filter(Boolean).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
// 2. 按日期汇总收支总值
|
||||
const incomeTotalMap: Record<string, number> = {};
|
||||
const expenseTotalMap: Record<string, number> = {};
|
||||
const accountDetailMap: Record<string, Record<string, { income: number; expense: number }>> = {};
|
||||
|
||||
// 初始化映射
|
||||
dateList.forEach(date => {
|
||||
incomeTotalMap[date] = 0;
|
||||
expenseTotalMap[date] = 0;
|
||||
accountDetailMap[date] = {};
|
||||
});
|
||||
|
||||
// 汇总数据
|
||||
validData.forEach(item => {
|
||||
const statDate = item.statDate!;
|
||||
const accountName = item.accountName || '未知账户';
|
||||
const incomeAmount = convertToTenThousand(Number(item.incomeAmount) || 0);
|
||||
const expenseAmount = convertToTenThousand(Number(item.expenseAmount) || 0);
|
||||
|
||||
// 累加总值
|
||||
incomeTotalMap[statDate] += incomeAmount;
|
||||
expenseTotalMap[statDate] += expenseAmount;
|
||||
|
||||
// 保存账户明细
|
||||
if (!accountDetailMap[statDate][accountName]) {
|
||||
accountDetailMap[statDate][accountName] = { income: 0, expense: 0 };
|
||||
}
|
||||
accountDetailMap[statDate][accountName].income += incomeAmount;
|
||||
accountDetailMap[statDate][accountName].expense += expenseAmount;
|
||||
});
|
||||
|
||||
// 3. 生成系列数据(收入和支出两个系列)
|
||||
const incomeSeries: BarSeriesOption = {
|
||||
...baseBarConfig,
|
||||
name: '收入',
|
||||
data: dateList.map(date => incomeTotalMap[date]),
|
||||
itemStyle: {
|
||||
...baseBarConfig.itemStyle,
|
||||
// 收入柱子添加渐变效果,增强视觉体验
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#73d13d' },
|
||||
{ offset: 1, color: '#52c41a' }
|
||||
])
|
||||
},
|
||||
label: {
|
||||
...baseBarConfig.label,
|
||||
color: '#52c41a'
|
||||
}
|
||||
};
|
||||
|
||||
const expenseSeries: BarSeriesOption = {
|
||||
...baseBarConfig,
|
||||
name: '支出',
|
||||
data: dateList.map(date => expenseTotalMap[date]),
|
||||
itemStyle: {
|
||||
...baseBarConfig.itemStyle,
|
||||
// 支出柱子添加渐变效果,增强视觉体验
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#ff4d4f' },
|
||||
{ offset: 1, color: '#f5222d' }
|
||||
])
|
||||
},
|
||||
label: {
|
||||
...baseBarConfig.label,
|
||||
color: '#f5222d'
|
||||
}
|
||||
};
|
||||
|
||||
const series: SeriesOption[] = [incomeSeries, expenseSeries];
|
||||
return {
|
||||
dateList,
|
||||
series,
|
||||
accountDetailMap,
|
||||
incomeTotalMap,
|
||||
expenseTotalMap
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化图表(改为总值显示,Tooltip改为表格形式)
|
||||
*/
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
// 销毁旧实例
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新实例
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
const { dateList, series, accountDetailMap, incomeTotalMap, expenseTotalMap } = processTableData();
|
||||
|
||||
// 空数据处理
|
||||
if (dateList.length === 0) {
|
||||
showEmptyChart();
|
||||
return;
|
||||
}
|
||||
|
||||
const option: EChartsOption = {
|
||||
title: {
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16, color: '#333' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontSize: 12 },
|
||||
padding: [10, 15],
|
||||
// Tooltip改为表格形式展示
|
||||
formatter: function(params: any) {
|
||||
if (!params || params.length === 0) return '';
|
||||
const date = params[0]?.axisValue || '';
|
||||
|
||||
// Tooltip中转换回元单位计算
|
||||
const incomeTotal = convertToYuan(incomeTotalMap[date] || 0);
|
||||
const expenseTotal = convertToYuan(expenseTotalMap[date] || 0);
|
||||
const netIncome = incomeTotal - expenseTotal;
|
||||
|
||||
const accountDetails = accountDetailMap[date] || {};
|
||||
const accountNames = Object.keys(accountDetails).sort();
|
||||
|
||||
// 生成账户明细表格行
|
||||
let accountTableRows = '';
|
||||
accountNames.forEach(account => {
|
||||
const detail = accountDetails[account];
|
||||
if (detail.income > 0 || detail.expense > 0) {
|
||||
// 转换回元单位显示
|
||||
const incomeValue = convertToYuan(detail.income);
|
||||
const expenseValue = convertToYuan(detail.expense);
|
||||
|
||||
accountTableRows += `
|
||||
<tr>
|
||||
<td style="padding: 4px 8px; text-align: left;">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; background: ${detail.income > 0 ? '#52c41a' : '#f5222d'}; border-radius: 2px; margin-right: 6px;"></span>
|
||||
${account}
|
||||
</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #52c41a;">${formatNumber(incomeValue)}</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #f5222d;">${formatNumber(expenseValue)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// 完整的表格结构Tooltip
|
||||
return `
|
||||
<div style="font-weight: 600; margin-bottom: 8px; text-align: center;">${date}</div>
|
||||
<!-- 账户明细表格 -->
|
||||
<table style="width: 100%; border-collapse: collapse; border: 1px solid #eee;">
|
||||
<thead>
|
||||
<tr style="background: #f8f8f8;">
|
||||
<th style="padding: 6px 8px; text-align: left; border: 1px solid #eee;">账户名称</th>
|
||||
<th style="padding: 6px 8px; text-align: right; border: 1px solid #eee; color: #52c41a;">收入(元)</th>
|
||||
<th style="padding: 6px 8px; text-align: right; border: 1px solid #eee; color: #f5222d;">支出(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${accountTableRows || '<tr><td colspan="3" style="padding: 8px; text-align: center; color: #999;">暂无明细数据</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 汇总行 -->
|
||||
<div style="border-top: 1px solid #eee; margin: 8px 0; padding-top: 8px; display: flex; justify-content: space-between; font-weight: 600;">
|
||||
<span style="display: flex; gap: 20px;">
|
||||
<span style="color: #52c41a;">总收入:${formatNumber(incomeTotal)} 元</span>
|
||||
<span style="color: #f5222d;">总支出:${formatNumber(expenseTotal)} 元</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="text-align: left; font-weight: 600; color: #333;">
|
||||
净收入:${formatNumber(netIncome)} 元
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
orient: 'horizontal',
|
||||
textStyle: { fontSize: 12, color: '#333' },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 20
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dateList,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
rotate: 45,
|
||||
interval: 0
|
||||
},
|
||||
axisTick: { inside: true },
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
min: 0,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
formatter: (value: any) => `${formatNumber(Number(value))} 万元`
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
max: (value: { max: number }) => value.max > 0 ? value.max * 1.15 : 0.1
|
||||
},
|
||||
series: series,
|
||||
responsive: true,
|
||||
// 取消堆叠模式,改为分组显示
|
||||
barGap: '20%',
|
||||
barCategoryGap: '30%'
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
} catch (error) {
|
||||
console.error('初始化图表失败:', error);
|
||||
showEmptyChart();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 窗口缩放自适应
|
||||
*/
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
clearTimeout((window as any).chartResizeTimer);
|
||||
(window as any).chartResizeTimer = setTimeout(() => {
|
||||
myChart?.resize();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听数据变化自动更新图表
|
||||
watch(tableData, () => {
|
||||
if (!loading.value) {
|
||||
initChart();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchList(props.formParams);
|
||||
window.addEventListener('resize', resizeChart);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeChart);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
clearTimeout((window as any).chartResizeTimer);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams); // 参数变化时重新调用fetchList
|
||||
},
|
||||
{ deep: true, immediate: false } // deep: true 监听对象内部变化
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 图例颜色 */
|
||||
:deep(.echarts-legend-item:nth-child(1) .echarts-legend-symbol) {
|
||||
background-color: #52c41a !important;
|
||||
border-radius: 4px; /* 图例也添加圆角,保持风格统一 */
|
||||
}
|
||||
:deep(.echarts-legend-item:nth-child(2) .echarts-legend-symbol) {
|
||||
background-color: #f5222d !important;
|
||||
border-radius: 4px; /* 图例也添加圆角,保持风格统一 */
|
||||
}
|
||||
|
||||
/* Tooltip样式优化(适配表格) */
|
||||
:deep(.echarts-tooltip) {
|
||||
border-radius: 8px;
|
||||
padding: 12px !important;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
border: none;
|
||||
background: #fff;
|
||||
z-index: 9999 !important;
|
||||
min-width: 400px; /* 保证表格有足够宽度 */
|
||||
}
|
||||
|
||||
/* 优化标签显示效果 */
|
||||
:deep(.echarts-bar-label) {
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 1px rgba(255,255,255,0.8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优化x轴标签显示 */
|
||||
:deep(.echarts-xaxis-label) {
|
||||
word-break: keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tooltip表格样式优化 */
|
||||
:deep(.echarts-tooltip table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip th) {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip td, .echarts-tooltip th) {
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
@@ -1,453 +0,0 @@
|
||||
<template>
|
||||
<Card title="支出分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></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 { ErpSummaryAll, erpSummaryAllListAll } from '@jeesite/erp/api/erp/summaryAll';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
const listSummary = ref<ErpSummaryAll[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
// ========== 核心工具函数 ==========
|
||||
// 保留2位小数(增强容错)
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return 0;
|
||||
return Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
// 元转万元(仅用于柱状图/Y轴展示)
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
};
|
||||
|
||||
// 千分位格式化(Tooltip显示原始元时使用)
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
// ========== 柱状图标签仅显示数值(无单位) ==========
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
// 折线图标签配置:百分比格式(保留单位)
|
||||
const lineLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 5,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#f5222d',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)} %`
|
||||
};
|
||||
|
||||
// 日期格式化(统一格式)
|
||||
const formatDate = (date: string | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.replace(/\//g, '-').trim();
|
||||
};
|
||||
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
try {
|
||||
const requestParams = {
|
||||
...params,
|
||||
ctype: '1'
|
||||
};
|
||||
|
||||
const result = await erpSummaryAllListAll(requestParams);
|
||||
|
||||
// 过滤空数据 + 统一日期格式 + 排序
|
||||
const validData = (result || [])
|
||||
.filter(item => item.cdate)
|
||||
.map(item => ({
|
||||
...item,
|
||||
cdate: formatDate(item.cdate)
|
||||
}))
|
||||
.sort((a, b) => a.cdate.localeCompare(b.cdate));
|
||||
|
||||
listSummary.value = validData;
|
||||
initChart();
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
listSummary.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 计算Y轴极值(适配万元展示层)
|
||||
const calculateYAxisExtent = (data: number[], isRate = false) => {
|
||||
if (data.length === 0) return isRate ? [-10, 10] : [0, 10]; // 万元默认范围
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
// 万元轴内边距:10% 或最小1万元
|
||||
const padding = isRate
|
||||
? Math.max(Math.abs(max) * 0.2, Math.abs(min) * 0.2, 5)
|
||||
: Math.max((max - min) * 0.1, 1);
|
||||
|
||||
let minExtent = min - padding;
|
||||
let maxExtent = max + padding;
|
||||
|
||||
// 强制包含0点
|
||||
if (minExtent > 0) minExtent = 0;
|
||||
if (maxExtent < 0) maxExtent = 0;
|
||||
|
||||
// 百分比轴正负对称
|
||||
if (isRate) {
|
||||
const absMax = Math.max(Math.abs(minExtent), Math.abs(maxExtent));
|
||||
minExtent = -absMax;
|
||||
maxExtent = absMax;
|
||||
}
|
||||
|
||||
return [minExtent, maxExtent];
|
||||
};
|
||||
|
||||
// 初始化图表(核心修复:Tooltip兼容处理)
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
// 提取X轴类目
|
||||
const xAxisData = listSummary.value.map(item => item.cdate).filter(Boolean);
|
||||
|
||||
// ========== 核心分离:展示数据(万元)和原始数据(元) ==========
|
||||
// 1. 展示层数据(万元):用于柱状图/Y轴/标签
|
||||
const thisValueShow = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return toTenThousandYuan(item?.thisValue);
|
||||
});
|
||||
const prevValueShow = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return toTenThousandYuan(item?.prevValue);
|
||||
});
|
||||
|
||||
// 2. 原始数据(元):直接从接口数据提取,不做任何换算,用于Tooltip
|
||||
const rawDataMap = new Map<string, { thisValue: any; prevValue: any; momRate: any }>();
|
||||
listSummary.value.forEach(item => {
|
||||
rawDataMap.set(item.cdate, {
|
||||
thisValue: item.thisValue, // 接口原始值(元)
|
||||
prevValue: item.prevValue, // 接口原始值(元)
|
||||
momRate: item.momRate // 接口原始值(%)
|
||||
});
|
||||
});
|
||||
|
||||
// 环比数据(不变)
|
||||
const rateData = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return formatNumber(item?.momRate);
|
||||
});
|
||||
|
||||
// 计算Y轴范围(基于万元展示数据)
|
||||
const amountData = [...thisValueShow, ...prevValueShow];
|
||||
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
|
||||
const [rateMin, rateMax] = calculateYAxisExtent(rateData, true);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 18, color: '#333' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontSize: 12 },
|
||||
padding: 10,
|
||||
// ========== 核心修复:兼容params长度不固定 + 空值容错 ==========
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '暂无数据';
|
||||
|
||||
const date = params[0]?.axisValue || '';
|
||||
const rawData = rawDataMap.get(date) || { thisValue: 0, prevValue: 0, momRate: 0 };
|
||||
|
||||
let res = `<div style="font-weight: 500; margin-bottom: 4px;">${date || '未知日期'}</div>`;
|
||||
|
||||
// 动态遍历params,不依赖固定索引
|
||||
params.forEach((param) => {
|
||||
// 跳过无效参数
|
||||
if (!param || !param.seriesName) return;
|
||||
|
||||
let displayText = '';
|
||||
const seriesName = param.seriesName;
|
||||
|
||||
// 根据系列名称匹配原始数据,避免依赖param.value
|
||||
if (seriesName === '本期金额') {
|
||||
displayText = `${param.marker || ''}本期金额:<span style="font-weight: 500;">${
|
||||
formatWithThousandsSeparator(rawData.thisValue)
|
||||
}</span> 元`;
|
||||
} else if (seriesName === '上期金额') {
|
||||
displayText = `${param.marker || ''}上期金额:<span style="font-weight: 500;">${
|
||||
formatWithThousandsSeparator(rawData.prevValue)
|
||||
}</span> 元`;
|
||||
} else if (seriesName === '环比') {
|
||||
displayText = `${param.marker || ''}环比:<span style="font-weight: 500;">${
|
||||
formatNumber(rawData.momRate).toFixed(2)
|
||||
}</span> %`;
|
||||
}
|
||||
|
||||
// 只有有效文本才添加到Tooltip
|
||||
if (displayText) {
|
||||
res += `<div style="margin: 2px 0;">${displayText}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['本期金额', '上期金额', '环比'],
|
||||
top: 10,
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisData,
|
||||
axisLabel: { fontSize: 12 },
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '交易金额(万元)', // Y轴仍保留万元单位
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(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: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
min: rateMin,
|
||||
max: rateMax,
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
noDataLoadingOption: {
|
||||
text: '暂无数据',
|
||||
textStyle: { fontSize: 14, color: '#999' },
|
||||
effect: 'bubble',
|
||||
effectOption: { effect: { n: 0 } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '本期金额',
|
||||
type: 'bar',
|
||||
data: thisValueShow, // 万元展示数据
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#096dd9' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
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: prevValueShow, // 万元展示数据
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#52c41a' },
|
||||
{ offset: 1, color: '#389e0d' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
label: barLabelConfig, // 仅显示数值的标签
|
||||
yAxisIndex: 0,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#73d13d' },
|
||||
{ offset: 1, color: '#52c41a' }
|
||||
])
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '环比',
|
||||
type: 'line',
|
||||
data: rateData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3, color: '#f5222d' },
|
||||
itemStyle: {
|
||||
color: '#f5222d',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: lineLabelConfig,
|
||||
emphasis: { itemStyle: { symbolSize: 12 } },
|
||||
yAxisIndex: 1,
|
||||
markLine: {
|
||||
silent: true,
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#999', type: 'dashed' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
};
|
||||
|
||||
// 调整图表大小(防抖+动画)
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
myChart.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticInOut'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口 resize 防抖处理
|
||||
let resizeTimer: number;
|
||||
const debounceResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resizeChart, 100);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await fetchList(props.formParams);
|
||||
window.addEventListener('resize', debounceResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', debounceResize);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams);
|
||||
},
|
||||
{ deep: true, immediate: false }
|
||||
);
|
||||
|
||||
// 监听数据变化自动更新图表
|
||||
watch(listSummary, () => {
|
||||
if (myChart) {
|
||||
initChart();
|
||||
}
|
||||
}, { deep: true });
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 优化图表tooltip样式 */
|
||||
:deep(.echarts-tooltip) {
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 优化表单样式 */
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 优化柱状图标签溢出处理 */
|
||||
:deep(.echarts-label) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优化Y轴名称间距 */
|
||||
:deep(.echarts-yaxis-name) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,453 +0,0 @@
|
||||
<template>
|
||||
<Card title="收入分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></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 { ErpSummaryAll, erpSummaryAllListAll } from '@jeesite/erp/api/erp/summaryAll';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
const listSummary = ref<ErpSummaryAll[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
// ========== 核心工具函数 ==========
|
||||
// 保留2位小数(增强容错)
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return 0;
|
||||
return Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
// 元转万元(仅用于柱状图/Y轴展示)
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
};
|
||||
|
||||
// 千分位格式化(Tooltip显示原始元时使用)
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
// ========== 柱状图标签仅显示数值(无单位) ==========
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
// 折线图标签配置:百分比格式(保留单位)
|
||||
const lineLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 5,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#f5222d',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)} %`
|
||||
};
|
||||
|
||||
// 日期格式化(统一格式)
|
||||
const formatDate = (date: string | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.replace(/\//g, '-').trim();
|
||||
};
|
||||
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
try {
|
||||
const requestParams = {
|
||||
...params,
|
||||
ctype: '2'
|
||||
};
|
||||
|
||||
const result = await erpSummaryAllListAll(requestParams);
|
||||
|
||||
// 过滤空数据 + 统一日期格式 + 排序
|
||||
const validData = (result || [])
|
||||
.filter(item => item.cdate)
|
||||
.map(item => ({
|
||||
...item,
|
||||
cdate: formatDate(item.cdate)
|
||||
}))
|
||||
.sort((a, b) => a.cdate.localeCompare(b.cdate));
|
||||
|
||||
listSummary.value = validData;
|
||||
initChart();
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
listSummary.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 计算Y轴极值(适配万元展示层)
|
||||
const calculateYAxisExtent = (data: number[], isRate = false) => {
|
||||
if (data.length === 0) return isRate ? [-10, 10] : [0, 10]; // 万元默认范围
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
// 万元轴内边距:10% 或最小1万元
|
||||
const padding = isRate
|
||||
? Math.max(Math.abs(max) * 0.2, Math.abs(min) * 0.2, 5)
|
||||
: Math.max((max - min) * 0.1, 1);
|
||||
|
||||
let minExtent = min - padding;
|
||||
let maxExtent = max + padding;
|
||||
|
||||
// 强制包含0点
|
||||
if (minExtent > 0) minExtent = 0;
|
||||
if (maxExtent < 0) maxExtent = 0;
|
||||
|
||||
// 百分比轴正负对称
|
||||
if (isRate) {
|
||||
const absMax = Math.max(Math.abs(minExtent), Math.abs(maxExtent));
|
||||
minExtent = -absMax;
|
||||
maxExtent = absMax;
|
||||
}
|
||||
|
||||
return [minExtent, maxExtent];
|
||||
};
|
||||
|
||||
// 初始化图表(核心修复:Tooltip兼容处理)
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
// 提取X轴类目
|
||||
const xAxisData = listSummary.value.map(item => item.cdate).filter(Boolean);
|
||||
|
||||
// ========== 核心分离:展示数据(万元)和原始数据(元) ==========
|
||||
// 1. 展示层数据(万元):用于柱状图/Y轴/标签
|
||||
const thisValueShow = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return toTenThousandYuan(item?.thisValue);
|
||||
});
|
||||
const prevValueShow = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return toTenThousandYuan(item?.prevValue);
|
||||
});
|
||||
|
||||
// 2. 原始数据(元):直接从接口数据提取,不做任何换算,用于Tooltip
|
||||
const rawDataMap = new Map<string, { thisValue: any; prevValue: any; momRate: any }>();
|
||||
listSummary.value.forEach(item => {
|
||||
rawDataMap.set(item.cdate, {
|
||||
thisValue: item.thisValue, // 接口原始值(元)
|
||||
prevValue: item.prevValue, // 接口原始值(元)
|
||||
momRate: item.momRate // 接口原始值(%)
|
||||
});
|
||||
});
|
||||
|
||||
// 环比数据(不变)
|
||||
const rateData = xAxisData.map(date => {
|
||||
const item = listSummary.value.find(i => i.cdate === date);
|
||||
return formatNumber(item?.momRate);
|
||||
});
|
||||
|
||||
// 计算Y轴范围(基于万元展示数据)
|
||||
const amountData = [...thisValueShow, ...prevValueShow];
|
||||
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
|
||||
const [rateMin, rateMax] = calculateYAxisExtent(rateData, true);
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 18, color: '#333' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontSize: 12 },
|
||||
padding: 10,
|
||||
// ========== 核心修复:兼容params长度不固定 + 空值容错 ==========
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '暂无数据';
|
||||
|
||||
const date = params[0]?.axisValue || '';
|
||||
const rawData = rawDataMap.get(date) || { thisValue: 0, prevValue: 0, momRate: 0 };
|
||||
|
||||
let res = `<div style="font-weight: 500; margin-bottom: 4px;">${date || '未知日期'}</div>`;
|
||||
|
||||
// 动态遍历params,不依赖固定索引
|
||||
params.forEach((param) => {
|
||||
// 跳过无效参数
|
||||
if (!param || !param.seriesName) return;
|
||||
|
||||
let displayText = '';
|
||||
const seriesName = param.seriesName;
|
||||
|
||||
// 根据系列名称匹配原始数据,避免依赖param.value
|
||||
if (seriesName === '本期金额') {
|
||||
displayText = `${param.marker || ''}本期金额:<span style="font-weight: 500;">${
|
||||
formatWithThousandsSeparator(rawData.thisValue)
|
||||
}</span> 元`;
|
||||
} else if (seriesName === '上期金额') {
|
||||
displayText = `${param.marker || ''}上期金额:<span style="font-weight: 500;">${
|
||||
formatWithThousandsSeparator(rawData.prevValue)
|
||||
}</span> 元`;
|
||||
} else if (seriesName === '环比') {
|
||||
displayText = `${param.marker || ''}环比:<span style="font-weight: 500;">${
|
||||
formatNumber(rawData.momRate).toFixed(2)
|
||||
}</span> %`;
|
||||
}
|
||||
|
||||
// 只有有效文本才添加到Tooltip
|
||||
if (displayText) {
|
||||
res += `<div style="margin: 2px 0;">${displayText}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['本期金额', '上期金额', '环比'],
|
||||
top: 10,
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisData,
|
||||
axisLabel: { fontSize: 12 },
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '交易金额(万元)', // Y轴仍保留万元单位
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(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: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
min: rateMin,
|
||||
max: rateMax,
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
noDataLoadingOption: {
|
||||
text: '暂无数据',
|
||||
textStyle: { fontSize: 14, color: '#999' },
|
||||
effect: 'bubble',
|
||||
effectOption: { effect: { n: 0 } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '本期金额',
|
||||
type: 'bar',
|
||||
data: thisValueShow, // 万元展示数据
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#096dd9' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
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: prevValueShow, // 万元展示数据
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#52c41a' },
|
||||
{ offset: 1, color: '#389e0d' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
label: barLabelConfig, // 仅显示数值的标签
|
||||
yAxisIndex: 0,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#73d13d' },
|
||||
{ offset: 1, color: '#52c41a' }
|
||||
])
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '环比',
|
||||
type: 'line',
|
||||
data: rateData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3, color: '#f5222d' },
|
||||
itemStyle: {
|
||||
color: '#f5222d',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: lineLabelConfig,
|
||||
emphasis: { itemStyle: { symbolSize: 12 } },
|
||||
yAxisIndex: 1,
|
||||
markLine: {
|
||||
silent: true,
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#999', type: 'dashed' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
};
|
||||
|
||||
// 调整图表大小(防抖+动画)
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
myChart.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticInOut'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口 resize 防抖处理
|
||||
let resizeTimer: number;
|
||||
const debounceResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resizeChart, 100);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await fetchList(props.formParams);
|
||||
window.addEventListener('resize', debounceResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', debounceResize);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams);
|
||||
},
|
||||
{ deep: true, immediate: false }
|
||||
);
|
||||
|
||||
// 监听数据变化自动更新图表
|
||||
watch(listSummary, () => {
|
||||
if (myChart) {
|
||||
initChart();
|
||||
}
|
||||
}, { deep: true });
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 优化图表tooltip样式 */
|
||||
:deep(.echarts-tooltip) {
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 优化表单样式 */
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 优化柱状图标签溢出处理 */
|
||||
:deep(.echarts-label) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优化Y轴名称间距 */
|
||||
:deep(.echarts-yaxis-name) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,550 +0,0 @@
|
||||
<template>
|
||||
<Card title="收支分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></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 { ErpIncExpRatio, erpIncExpRatioListAll } from '@jeesite/erp/api/erp/incExpRatio';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
const rawData = ref<ErpIncExpRatio[]>([]); // 存储原始日期维度数据
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
// ========== 核心工具函数 ==========
|
||||
// 保留2位小数(增强容错)
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return 0;
|
||||
return Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
// 元转万元(用于柱状图/Y轴展示)
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
};
|
||||
|
||||
// 千分位格式化(Tooltip显示原始元时使用)
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
// 计算净收益(收入 - 支出)
|
||||
const calculateNetProfit = (income: number | string | undefined, expense: number | string | undefined): number => {
|
||||
const incomeNum = formatNumber(income);
|
||||
const expenseNum = formatNumber(expense);
|
||||
return formatNumber(incomeNum - expenseNum);
|
||||
};
|
||||
|
||||
// 日期格式化(统一格式)
|
||||
const formatDate = (date: string | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.replace(/\//g, '-').trim();
|
||||
};
|
||||
|
||||
// 日期排序函数(X轴按时间升序)
|
||||
const sortDates = (data: ErpIncExpRatio[]): ErpIncExpRatio[] => {
|
||||
return data.sort((a, b) => {
|
||||
const dateA = new Date(a.statDate || '');
|
||||
const dateB = new Date(b.statDate || '');
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// ========== 图表标签配置 ==========
|
||||
// 柱状图标签配置(万元,保留2位小数)
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
// 折线图标签配置(百分比格式)
|
||||
const lineLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 5,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#52c41a'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)} %`
|
||||
};
|
||||
|
||||
// ========== 数据请求与处理 ==========
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
try {
|
||||
const result = await erpIncExpRatioListAll(params);
|
||||
|
||||
// 数据处理:过滤空日期 + 格式化 + 排序
|
||||
const validData = (result || [])
|
||||
.filter(item => item.statDate) // 过滤无日期的数据
|
||||
.map(item => ({
|
||||
statDate: formatDate(item.statDate),
|
||||
incomeAmount: formatNumber(item.incomeAmount),
|
||||
expenseAmount: formatNumber(item.expenseAmount),
|
||||
expenseRatio: formatNumber(item.expenseRatio)
|
||||
})) as ErpIncExpRatio[];
|
||||
|
||||
// 按日期升序排序
|
||||
rawData.value = sortDates(validData);
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
rawData.value = [];
|
||||
createMessage.error('数据加载失败,请稍后重试');
|
||||
} finally {
|
||||
initChart();
|
||||
}
|
||||
};
|
||||
|
||||
// 计算Y轴极值
|
||||
const calculateYAxisExtent = (data: number[], isRate = false) => {
|
||||
if (data.length === 0) return isRate ? [0, 100] : [0, 10];
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
const padding = isRate
|
||||
? Math.max((100 - max) * 0.1, 5)
|
||||
: 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;
|
||||
|
||||
// 占比轴限制0-100%
|
||||
if (isRate) {
|
||||
minExtent = Math.max(minExtent, 0);
|
||||
maxExtent = Math.min(maxExtent, 100);
|
||||
}
|
||||
|
||||
return [minExtent, maxExtent];
|
||||
};
|
||||
|
||||
// ========== 图表初始化 ==========
|
||||
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: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [],
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
overflow: 'truncate',
|
||||
width: 80
|
||||
},
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '支出占比(%)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
series: [],
|
||||
noDataLoadingOption: {
|
||||
text: '暂无收入支出数据',
|
||||
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
|
||||
position: 'center',
|
||||
effect: 'none'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取X轴日期
|
||||
const xAxisData = rawData.value.map(item => item.statDate!).filter(Boolean);
|
||||
|
||||
// 提取图表数据
|
||||
const incomeData = rawData.value.map(item => toTenThousandYuan(item.incomeAmount));
|
||||
const expenseData = rawData.value.map(item => toTenThousandYuan(item.expenseAmount));
|
||||
const ratioData = rawData.value.map(item => formatNumber(item.expenseRatio));
|
||||
|
||||
// 计算Y轴范围
|
||||
const amountData = [...incomeData, ...expenseData];
|
||||
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
|
||||
const [ratioMin, ratioMax] = calculateYAxisExtent(ratioData, true);
|
||||
|
||||
// 图表配置
|
||||
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 currentDate = params[0]?.axisValue || '';
|
||||
const item = rawData.value.find(i => i.statDate === currentDate);
|
||||
|
||||
if (!item) return `<div style="padding: 8px;">${currentDate}:暂无明细</div>`;
|
||||
|
||||
// 计算净收益
|
||||
const netProfit = calculateNetProfit(item.incomeAmount, item.expenseAmount);
|
||||
// 净收益颜色:正数绿色,负数红色,零灰色
|
||||
const netProfitColor = netProfit > 0 ? '#52c41a' : netProfit < 0 ? '#f5222d' : '#666';
|
||||
|
||||
return `
|
||||
<div style="font-weight: 600; color: #333; margin-bottom: 8px; text-align: center;">${currentDate}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #1890ff;">${formatWithThousandsSeparator(item.incomeAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #f5222d;">${formatWithThousandsSeparator(item.expenseAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: ${netProfitColor};">${formatWithThousandsSeparator(netProfit)} 元</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="padding: 8px; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa; font-size: 11px;">
|
||||
<span style="font-weight: 500; color: #333;">支出占比:</span>
|
||||
<span style="color: #52c41a;">${formatNumber(item.expenseRatio).toFixed(2)} %</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出', '支出占比'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%',
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisData,
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45,
|
||||
overflow: 'truncate',
|
||||
width: 80,
|
||||
lineHeight: 1.5
|
||||
},
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(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: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
min: ratioMin,
|
||||
max: ratioMax,
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
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: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
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: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
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: ratioData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3, color: '#52c41a' },
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: lineLabelConfig,
|
||||
emphasis: { itemStyle: { symbolSize: 12 } },
|
||||
yAxisIndex: 1,
|
||||
markLine: {
|
||||
silent: true,
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#999', type: 'dashed' } }]
|
||||
}
|
||||
}
|
||||
],
|
||||
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(props.formParams);
|
||||
window.addEventListener('resize', debounceResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', debounceResize);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听参数变化重新请求数据
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams);
|
||||
},
|
||||
{ deep: true, immediate: false }
|
||||
);
|
||||
|
||||
// 监听数据变化更新图表
|
||||
watch(rawData, () => {
|
||||
initChart();
|
||||
}, { deep: true });
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 优化Tooltip样式 */
|
||||
: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.5;
|
||||
}
|
||||
|
||||
:deep(.echarts-yaxis-name) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* 优化图例样式 */
|
||||
:deep(.echarts-legend-item) {
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
|
||||
/* 优化柱状图标签 */
|
||||
:deep(.echarts-label) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tooltip滚动条优化 */
|
||||
: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>
|
||||
@@ -1,859 +0,0 @@
|
||||
<template>
|
||||
<Card title="用途分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<div ref="chartDom" style="width: 100%; height: 300px;"></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 { ErpCategoryFlow, erpCategoryFlowListAll } from '@jeesite/erp/api/erp/categoryFlow';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
// 定义类型增强
|
||||
interface CategoryFlowDetail extends ErpCategoryFlow {
|
||||
statDate: string;
|
||||
categoryName: string;
|
||||
thisIncome?: number;
|
||||
prevIncome?: number;
|
||||
incomeMom?: number;
|
||||
thisExpense?: number;
|
||||
prevExpense?: number;
|
||||
expenseMom?: number;
|
||||
// 新增净收益相关字段
|
||||
thisNetProfit?: number; // 本期净收益 = 本期收入 - 本期支出
|
||||
prevNetProfit?: number; // 上期净收益 = 上期收入 - 上期支出
|
||||
netProfitMom?: number; // 净收益环比
|
||||
}
|
||||
|
||||
// 聚合后的数据类型(按日期聚合)
|
||||
interface AggregatedData {
|
||||
statDate: string; // 日期
|
||||
totalThisIncome: number; // 当日所有分类本期收入总和(元)
|
||||
totalPrevIncome: number; // 当日所有分类上期收入总和(元)
|
||||
totalThisExpense: number; // 当日所有分类本期支出总和(元)
|
||||
totalPrevExpense: number; // 当日所有分类上期支出总和(元)
|
||||
totalThisNetProfit: number; // 当日所有分类本期净收益总和(元)
|
||||
totalPrevNetProfit: number; // 当日所有分类上期净收益总和(元)
|
||||
avgIncomeMom: number; // 当日所有分类收入环比平均值(%)
|
||||
avgExpenseMom: number; // 当日所有分类支出环比平均值(%)
|
||||
avgNetProfitMom: number; // 当日所有分类净收益环比平均值(%)
|
||||
details: CategoryFlowDetail[]; // 当日所有分类的明细数据
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>; // 接收周期等参数
|
||||
}>();
|
||||
|
||||
const listErpCategory = ref<CategoryFlowDetail[]>([]);
|
||||
const aggregatedData = ref<AggregatedData[]>([]); // 按日期聚合后的数据
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
// ========== 核心工具函数 ==========
|
||||
// 保留2位小数(增强容错)
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return 0;
|
||||
return Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
// 元转万元(仅用于柱状图/Y轴展示)
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
};
|
||||
|
||||
// 千分位格式化(Tooltip显示原始元时使用)
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
// 日期比较函数(升序,保证X轴日期从早到晚)
|
||||
const compareDateAsc = (a: AggregatedData, b: AggregatedData): number => {
|
||||
const dateA = new Date(a.statDate || '');
|
||||
const dateB = new Date(b.statDate || '');
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
};
|
||||
|
||||
// ========== 柱状图标签仅显示数值(无单位) ==========
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
// 折线图标签配置:百分比格式(保留单位)
|
||||
const lineLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 5,
|
||||
textStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)} %`
|
||||
};
|
||||
|
||||
// 日期格式化(统一格式)
|
||||
const formatDate = (date: string | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.replace(/\//g, '-').trim();
|
||||
};
|
||||
|
||||
// 计算环比值
|
||||
const calculateMom = (current: number, previous: number): number => {
|
||||
if (previous === 0) return 0;
|
||||
return formatNumber(((current - previous) / previous) * 100);
|
||||
};
|
||||
|
||||
// ========== 数据聚合函数:按日期聚合 + 汇总当日所有分类 ==========
|
||||
const aggregateDataByDate = (rawData: CategoryFlowDetail[]): AggregatedData[] => {
|
||||
const dateMap = new Map<string, AggregatedData>();
|
||||
|
||||
rawData.forEach(item => {
|
||||
if (!item.statDate || !item.categoryName) return;
|
||||
const formattedDate = formatDate(item.statDate);
|
||||
|
||||
// 计算净收益
|
||||
const thisNetProfit = formatNumber(item.thisIncome) - formatNumber(item.thisExpense);
|
||||
const prevNetProfit = formatNumber(item.prevIncome) - formatNumber(item.prevExpense);
|
||||
const netProfitMom = calculateMom(thisNetProfit, prevNetProfit);
|
||||
|
||||
// 补全净收益字段
|
||||
const itemWithNetProfit = {
|
||||
...item,
|
||||
thisNetProfit,
|
||||
prevNetProfit,
|
||||
netProfitMom
|
||||
};
|
||||
|
||||
// 初始化日期数据
|
||||
if (!dateMap.has(formattedDate)) {
|
||||
dateMap.set(formattedDate, {
|
||||
statDate: formattedDate,
|
||||
totalThisIncome: 0,
|
||||
totalPrevIncome: 0,
|
||||
totalThisExpense: 0,
|
||||
totalPrevExpense: 0,
|
||||
totalThisNetProfit: 0,
|
||||
totalPrevNetProfit: 0,
|
||||
avgIncomeMom: 0,
|
||||
avgExpenseMom: 0,
|
||||
avgNetProfitMom: 0,
|
||||
details: []
|
||||
});
|
||||
}
|
||||
|
||||
const dateData = dateMap.get(formattedDate)!;
|
||||
|
||||
// 累加当日金额(原始元)
|
||||
dateData.totalThisIncome += formatNumber(itemWithNetProfit.thisIncome);
|
||||
dateData.totalPrevIncome += formatNumber(itemWithNetProfit.prevIncome);
|
||||
dateData.totalThisExpense += formatNumber(itemWithNetProfit.thisExpense);
|
||||
dateData.totalPrevExpense += formatNumber(itemWithNetProfit.prevExpense);
|
||||
dateData.totalThisNetProfit += thisNetProfit;
|
||||
dateData.totalPrevNetProfit += prevNetProfit;
|
||||
|
||||
// 收集当日分类明细
|
||||
dateData.details.push({
|
||||
...itemWithNetProfit,
|
||||
statDate: formattedDate,
|
||||
categoryName: itemWithNetProfit.categoryName.trim()
|
||||
});
|
||||
});
|
||||
|
||||
// 处理每个日期的最终数据
|
||||
const result: AggregatedData[] = Array.from(dateMap.values()).map(item => {
|
||||
// 1. 计算当日环比平均值(过滤0值)
|
||||
const incomeMomList = item.details.map(d => formatNumber(d.incomeMom)).filter(v => v !== 0);
|
||||
const expenseMomList = item.details.map(d => formatNumber(d.expenseMom)).filter(v => v !== 0);
|
||||
const netProfitMomList = item.details.map(d => formatNumber(d.netProfitMom)).filter(v => v !== 0);
|
||||
|
||||
item.avgIncomeMom = incomeMomList.length
|
||||
? formatNumber(incomeMomList.reduce((a, b) => a + b, 0) / incomeMomList.length)
|
||||
: 0;
|
||||
|
||||
item.avgExpenseMom = expenseMomList.length
|
||||
? formatNumber(expenseMomList.reduce((a, b) => a + b, 0) / expenseMomList.length)
|
||||
: 0;
|
||||
|
||||
item.avgNetProfitMom = netProfitMomList.length
|
||||
? formatNumber(netProfitMomList.reduce((a, b) => a + b, 0) / netProfitMomList.length)
|
||||
: 0;
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// 按日期升序排序(X轴从早到晚)
|
||||
return result.sort(compareDateAsc);
|
||||
};
|
||||
|
||||
const fetchList = async (params: Record<string, any>) => {
|
||||
try {
|
||||
const result = await erpCategoryFlowListAll(params);
|
||||
|
||||
// 过滤空数据 + 统一日期格式 + 类型转换 + 计算净收益
|
||||
const validData = (result || [])
|
||||
.filter(item => item.categoryName && item.statDate)
|
||||
.map(item => {
|
||||
// 基础字段格式化
|
||||
const thisIncome = formatNumber(item.thisIncome);
|
||||
const prevIncome = formatNumber(item.prevIncome);
|
||||
const thisExpense = formatNumber(item.thisExpense);
|
||||
const prevExpense = formatNumber(item.prevExpense);
|
||||
|
||||
// 计算净收益
|
||||
const thisNetProfit = thisIncome - thisExpense;
|
||||
const prevNetProfit = prevIncome - prevExpense;
|
||||
const netProfitMom = calculateMom(thisNetProfit, prevNetProfit);
|
||||
|
||||
return {
|
||||
...item,
|
||||
statDate: formatDate(item.statDate),
|
||||
categoryName: item.categoryName.trim(),
|
||||
thisIncome,
|
||||
prevIncome,
|
||||
incomeMom: formatNumber(item.incomeMom),
|
||||
thisExpense,
|
||||
prevExpense,
|
||||
expenseMom: formatNumber(item.expenseMom),
|
||||
thisNetProfit: formatNumber(thisNetProfit),
|
||||
prevNetProfit: formatNumber(prevNetProfit),
|
||||
netProfitMom: formatNumber(netProfitMom)
|
||||
} as CategoryFlowDetail;
|
||||
});
|
||||
|
||||
listErpCategory.value = validData;
|
||||
// 按日期聚合数据
|
||||
aggregatedData.value = aggregateDataByDate(validData);
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
// 异常时强制清空所有数据
|
||||
listErpCategory.value = [];
|
||||
aggregatedData.value = [];
|
||||
createMessage.error('数据加载失败,请稍后重试');
|
||||
} finally {
|
||||
// 无论成功/失败/空数据,最终都执行图表初始化
|
||||
initChart();
|
||||
}
|
||||
};
|
||||
|
||||
// 计算Y轴极值(适配万元展示层)
|
||||
const calculateYAxisExtent = (data: number[], isRate = false) => {
|
||||
if (data.length === 0) return isRate ? [-10, 10] : [0, 10]; // 万元默认范围
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
// 万元轴内边距:10% 或最小1万元
|
||||
const padding = isRate
|
||||
? Math.max(Math.abs(max) * 0.2, Math.abs(min) * 0.2, 5)
|
||||
: Math.max((max - min) * 0.1, 1);
|
||||
|
||||
let minExtent = min - padding;
|
||||
let maxExtent = max + padding;
|
||||
|
||||
// 强制包含0点
|
||||
if (minExtent > 0) minExtent = 0;
|
||||
if (maxExtent < 0) maxExtent = 0;
|
||||
|
||||
// 百分比轴正负对称
|
||||
if (isRate) {
|
||||
const absMax = Math.max(Math.abs(minExtent), Math.abs(maxExtent));
|
||||
minExtent = -absMax;
|
||||
maxExtent = absMax;
|
||||
}
|
||||
|
||||
return [minExtent, maxExtent];
|
||||
};
|
||||
|
||||
// 初始化图表(核心优化:X轴为日期 + 按日期汇总金额 + Tooltip展示分类明细)
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
// 销毁旧实例,避免数据残留
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
// ========== 空数据处理逻辑 ==========
|
||||
if (aggregatedData.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: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%', // 增加底部间距适配日期标签
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [],
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45, // 日期旋转45度避免重叠
|
||||
overflow: 'truncate',
|
||||
width: 80
|
||||
},
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '环比(%)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
series: [], // 清空所有系列数据
|
||||
noDataLoadingOption: {
|
||||
text: '暂无收入支出数据',
|
||||
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
|
||||
position: 'center',
|
||||
effect: 'none' // 关闭空数据动画,提升体验
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常数据处理逻辑
|
||||
// 提取X轴类目(日期)
|
||||
const xAxisData = aggregatedData.value.map(item => item.statDate).filter(Boolean);
|
||||
|
||||
// 按日期汇总的金额数据(转万元)
|
||||
const thisIncomeShow = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return toTenThousandYuan(item?.totalThisIncome);
|
||||
});
|
||||
const prevIncomeShow = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return toTenThousandYuan(item?.totalPrevIncome);
|
||||
});
|
||||
const thisExpenseShow = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return toTenThousandYuan(item?.totalThisExpense);
|
||||
});
|
||||
const prevExpenseShow = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return toTenThousandYuan(item?.totalPrevExpense);
|
||||
});
|
||||
|
||||
// 环比数据(当日平均值)
|
||||
const incomeMomData = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return formatNumber(item?.avgIncomeMom);
|
||||
});
|
||||
const expenseMomData = xAxisData.map(date => {
|
||||
const item = aggregatedData.value.find(i => i.statDate === date);
|
||||
return formatNumber(item?.avgExpenseMom);
|
||||
});
|
||||
|
||||
// 计算Y轴范围(基于万元展示数据)
|
||||
const amountData = [...thisIncomeShow, ...prevIncomeShow, ...thisExpenseShow, ...prevExpenseShow];
|
||||
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
|
||||
const rateData = [...incomeMomData, ...expenseMomData];
|
||||
const [rateMin, rateMax] = calculateYAxisExtent(rateData, true);
|
||||
|
||||
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 currentDate = params[0]?.axisValue || '';
|
||||
const dateData = aggregatedData.value.find(item => item.statDate === currentDate);
|
||||
|
||||
if (!dateData) return `<div style="padding: 8px;">${currentDate}:暂无明细</div>`;
|
||||
|
||||
// 样式定义
|
||||
const tableHeaderStyle = 'background: #f8f9fa; padding: 6px 8px; text-align: center; font-weight: 600; border: 1px solid #e8e8e8;';
|
||||
const tableCellStyle = 'padding: 6px 8px; text-align: center; border: 1px solid #e8e8e8;';
|
||||
const totalCellStyle = 'padding: 6px 8px; text-align: center; border: 1px solid #e8e8e8; background: #e6f7ff; font-weight: 600; color: #1890ff;';
|
||||
const summaryStyle = 'font-weight: 600; color: #333; margin-bottom: 8px; text-align: center;';
|
||||
|
||||
// 当日汇总数据
|
||||
const totalThisIncome = formatWithThousandsSeparator(dateData.totalThisIncome);
|
||||
const totalPrevIncome = formatWithThousandsSeparator(dateData.totalPrevIncome);
|
||||
const totalThisExpense = formatWithThousandsSeparator(dateData.totalThisExpense);
|
||||
const totalPrevExpense = formatWithThousandsSeparator(dateData.totalPrevExpense);
|
||||
const totalThisNetProfit = formatWithThousandsSeparator(dateData.totalThisNetProfit);
|
||||
const totalPrevNetProfit = formatWithThousandsSeparator(dateData.totalPrevNetProfit);
|
||||
|
||||
// 构建Tooltip内容
|
||||
let tooltipContent = `
|
||||
<div style="${summaryStyle}">${currentDate}</div>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 11px; min-width: 900px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="${tableHeaderStyle}">分类名称</th>
|
||||
<th style="${tableHeaderStyle}">本期收入(元)</th>
|
||||
<th style="${tableHeaderStyle}">上期收入(元)</th>
|
||||
<th style="${tableHeaderStyle}">收入环比(%)</th>
|
||||
<th style="${tableHeaderStyle}">本期支出(元)</th>
|
||||
<th style="${tableHeaderStyle}">上期支出(元)</th>
|
||||
<th style="${tableHeaderStyle}">支出环比(%)</th>
|
||||
<th style="${tableHeaderStyle}">本期净收益(元)</th>
|
||||
<th style="${tableHeaderStyle}">上期净收益(元)</th>
|
||||
<th style="${tableHeaderStyle}">净收益环比(%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
// 添加分类明细行
|
||||
dateData.details.forEach(detail => {
|
||||
tooltipContent += `
|
||||
<tr>
|
||||
<td style="${tableCellStyle}; text-align: left; padding-left: 12px;">${detail.categoryName || '-'}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.thisIncome)}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.prevIncome)}</td>
|
||||
<td style="${tableCellStyle}">${detail.incomeMom?.toFixed(2) || '0.00'}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.thisExpense)}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.prevExpense)}</td>
|
||||
<td style="${tableCellStyle}">${detail.expenseMom?.toFixed(2) || '0.00'}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.thisNetProfit)}</td>
|
||||
<td style="${tableCellStyle}">${formatWithThousandsSeparator(detail.prevNetProfit)}</td>
|
||||
<td style="${tableCellStyle}">${detail.netProfitMom?.toFixed(2) || '0.00'}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
// 添加当日合计行
|
||||
tooltipContent += `
|
||||
<tr>
|
||||
<td style="${totalCellStyle}; text-align: left; padding-left: 12px;">本期合计</td>
|
||||
<td style="${totalCellStyle}">${totalThisIncome}</td>
|
||||
<td style="${totalCellStyle}">${totalPrevIncome}</td>
|
||||
<td style="${totalCellStyle}">${dateData.avgIncomeMom.toFixed(2)}</td>
|
||||
<td style="${totalCellStyle}">${totalThisExpense}</td>
|
||||
<td style="${totalCellStyle}">${totalPrevExpense}</td>
|
||||
<td style="${totalCellStyle}">${dateData.avgExpenseMom.toFixed(2)}</td>
|
||||
<td style="${totalCellStyle}">${totalThisNetProfit}</td>
|
||||
<td style="${totalCellStyle}">${totalPrevNetProfit}</td>
|
||||
<td style="${totalCellStyle}">${dateData.avgNetProfitMom.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
return tooltipContent;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['本期收入', '上期收入', '本期支出', '上期支出', '收入环比', '支出环比'],
|
||||
top: 10,
|
||||
textStyle: { fontSize: 12 },
|
||||
// 自动换行防止图例溢出
|
||||
formatter: (name: string) => {
|
||||
const maxLength = 6;
|
||||
if (name.length > maxLength) {
|
||||
return name.substring(0, maxLength) + '...';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
bottom: '1%', // 增加底部间距适配日期标签
|
||||
top: '20%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxisData,
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
rotate: 45, // 日期旋转45度避免重叠
|
||||
overflow: 'truncate',
|
||||
width: 80
|
||||
},
|
||||
axisLine: { onZero: true }
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(万元)',
|
||||
nameTextStyle: { fontSize: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => formatNumber(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: 12 },
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (value: number) => `${formatNumber(value).toFixed(2)} %`
|
||||
},
|
||||
min: rateMin,
|
||||
max: rateMax,
|
||||
position: 'right',
|
||||
offset: 0,
|
||||
splitLine: { show: false },
|
||||
axisLine: { onZero: true },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 4
|
||||
}
|
||||
],
|
||||
noDataLoadingOption: {
|
||||
text: '暂无数据',
|
||||
textStyle: { fontSize: 14, color: '#999' },
|
||||
effect: 'bubble',
|
||||
effectOption: { effect: { n: 0 } }
|
||||
},
|
||||
series: [
|
||||
// 本期收入(当日汇总)- 深蓝色系
|
||||
{
|
||||
name: '本期收入',
|
||||
type: 'bar',
|
||||
data: thisIncomeShow,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#096dd9' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
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: prevIncomeShow,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#722ed1' },
|
||||
{ offset: 1, color: '#531dab' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
label: barLabelConfig,
|
||||
yAxisIndex: 0,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#9254de' },
|
||||
{ offset: 1, color: '#722ed1' }
|
||||
])
|
||||
}
|
||||
}
|
||||
},
|
||||
// 本期支出(当日汇总)- 红色系
|
||||
{
|
||||
name: '本期支出',
|
||||
type: 'bar',
|
||||
data: thisExpenseShow,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#f5222d' },
|
||||
{ offset: 1, color: '#cf1322' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
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: 'bar',
|
||||
data: prevExpenseShow,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#fa8c16' },
|
||||
{ offset: 1, color: '#d46b08' }
|
||||
]),
|
||||
borderRadius: [8, 8, 0, 0]
|
||||
},
|
||||
barWidth: 15,
|
||||
barBorderRadius: [8, 8, 0, 0],
|
||||
label: barLabelConfig,
|
||||
yAxisIndex: 0,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#ffa940' },
|
||||
{ offset: 1, color: '#fa8c16' }
|
||||
])
|
||||
}
|
||||
}
|
||||
},
|
||||
// 收入环比(当日平均值)- 绿色系
|
||||
{
|
||||
name: '收入环比',
|
||||
type: 'line',
|
||||
data: incomeMomData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3, color: '#52c41a' },
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
...lineLabelConfig,
|
||||
textStyle: { ...lineLabelConfig.textStyle, color: '#52c41a' }
|
||||
},
|
||||
emphasis: { itemStyle: { symbolSize: 12 } },
|
||||
yAxisIndex: 1,
|
||||
markLine: {
|
||||
silent: true,
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#999', type: 'dashed' } }]
|
||||
}
|
||||
},
|
||||
// 支出环比(当日平均值)- 紫色系
|
||||
{
|
||||
name: '支出环比',
|
||||
type: 'line',
|
||||
data: expenseMomData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3, color: '#eb2f96' },
|
||||
itemStyle: {
|
||||
color: '#eb2f96',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
...lineLabelConfig,
|
||||
textStyle: { ...lineLabelConfig.textStyle, color: '#eb2f96' }
|
||||
},
|
||||
emphasis: { itemStyle: { symbolSize: 12 } },
|
||||
yAxisIndex: 1,
|
||||
markLine: {
|
||||
silent: true,
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
data: [{ yAxis: 0, lineStyle: { color: '#999', type: 'dashed' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
};
|
||||
|
||||
// 调整图表大小(防抖+动画)
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
myChart.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticInOut'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 窗口 resize 防抖处理
|
||||
let resizeTimer: number;
|
||||
const debounceResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resizeChart, 100);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await fetchList(props.formParams);
|
||||
window.addEventListener('resize', debounceResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', debounceResize);
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.formParams,
|
||||
async (newParams) => {
|
||||
await fetchList(newParams);
|
||||
},
|
||||
{ deep: true, immediate: false }
|
||||
);
|
||||
|
||||
// 监听聚合数据变化自动更新图表(空数据也触发)
|
||||
watch(aggregatedData, () => {
|
||||
initChart();
|
||||
}, { deep: true });
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 优化图表tooltip样式 */
|
||||
: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: 1050px; /* 扩大Tooltip最大宽度适配新增列 */
|
||||
max-height: 500px; /* 扩大Tooltip最大高度 */
|
||||
overflow-y: auto; /* 超出滚动 */
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* 优化Tooltip表格样式 */
|
||||
:deep(.echarts-tooltip table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 900px; /* 适配新增净收益列的表格宽度 */
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip th) {
|
||||
background: #f8f9fa !important;
|
||||
font-weight: 600 !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip td) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 合计行样式增强 */
|
||||
:deep(.echarts-tooltip tr:last-child td) {
|
||||
background: #e6f7ff !important;
|
||||
color: #1890ff !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* 优化柱状图标签溢出处理 */
|
||||
:deep(.echarts-label) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 优化Y轴名称间距 */
|
||||
:deep(.echarts-yaxis-name) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* 优化X轴日期标签显示 */
|
||||
:deep(.echarts-xaxis-label) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.5; /* 增加行高适配旋转后的日期 */
|
||||
}
|
||||
|
||||
/* 调整图例间距 */
|
||||
:deep(.echarts-legend-item) {
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
|
||||
/* Tooltip滚动条样式优化 */
|
||||
: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>
|
||||
@@ -1,473 +0,0 @@
|
||||
<template>
|
||||
<Card title="占比分析" style="width: 100%; height: 400px; margin: 4px 0;">
|
||||
<template #extra>
|
||||
<div class="total-amount">
|
||||
总金额:<span class="amount-value">{{ totalAmountText }}</span> 元
|
||||
</div>
|
||||
</template>
|
||||
<div class="layout-container">
|
||||
<div class="left-panel">
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">本月收入</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-income.png" size="36"/>
|
||||
<span class="stat-value">{{ thisIncome }} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">本月支出</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-expense.png" size="36"/>
|
||||
<span class="stat-value">{{ thisExpense }} 元</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">消费占比</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-share.png" size="36"/>
|
||||
<span class="stat-value">{{ thisShare }} %</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<div ref="chartDom" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { Icon } from '@jeesite/core/components/Icon';
|
||||
import { ErpAccount, erpAccountListAll } from '@jeesite/erp/api/erp/account';
|
||||
import { ErpIncExpRatio, erpIncExpRatioListAll } from '@jeesite/erp/api/erp/incExpRatio';
|
||||
import * as echarts from 'echarts';
|
||||
import type { ECharts, EChartsOption } from 'echarts';
|
||||
|
||||
const listAccount = ref<ErpAccount[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: ECharts | null = null;
|
||||
|
||||
const thisIncome = ref(0);
|
||||
const thisExpense = ref(0);
|
||||
const thisShare = ref(0);
|
||||
|
||||
// 核心标记:是否全取消 + 维护所有图例名称的集合
|
||||
const allLegendNames = ref<Set<string>>(new Set());
|
||||
const selectedLegends = ref<Set<string>>(new Set());
|
||||
const isAllUnselected = ref(false);
|
||||
|
||||
// 总金额计算逻辑
|
||||
const totalAmountText = computed(() => {
|
||||
if (isAllUnselected.value) {
|
||||
return '0.00';
|
||||
}
|
||||
|
||||
if (selectedLegends.value.size === 0 && !isAllUnselected.value) {
|
||||
const total = listAccount.value.reduce(
|
||||
(sum, item) => sum + (Number(item.currentBalance) || 0),
|
||||
0
|
||||
);
|
||||
return total.toFixed(2);
|
||||
}
|
||||
|
||||
const total = listAccount.value
|
||||
.filter(item => selectedLegends.value.has(item.accountName || '未知账户'))
|
||||
.reduce((sum, item) => sum + (Number(item.currentBalance) || 0), 0);
|
||||
|
||||
return total.toFixed(2);
|
||||
});
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// 优化颜色生成逻辑,增加颜色复用规则
|
||||
const generateRandomColors = (count: number): string[] => {
|
||||
const colorLibrary = [
|
||||
'#1890ff', '#52c41a', '#f5a623', '#fa8c16', '#722ed1', '#eb2f96',
|
||||
'#13c2c2', '#2f54eb', '#f7ba1e', '#f5222d', '#8543e0', '#0fc6c2',
|
||||
'#7cb305', '#ff7a45', '#ff4d4f', '#6b778c', '#5d7092', '#91d5ff'
|
||||
];
|
||||
|
||||
if (count <= colorLibrary.length) {
|
||||
return colorLibrary.slice(0, count);
|
||||
}
|
||||
|
||||
const colors: string[] = [...colorLibrary];
|
||||
while (colors.length < count) {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
colors.push(`hsl(${hue}, 75%, 55%)`);
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const getThisData = async() => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const formattedMonth = month.toString().padStart(2, '0');
|
||||
const currentMonthStr = `${year}-${formattedMonth}`;
|
||||
const params = {
|
||||
cycleType: 'M',
|
||||
statDate: currentMonthStr ,
|
||||
}
|
||||
const result = await erpIncExpRatioListAll(params);
|
||||
thisIncome.value = result[0]?.incomeAmount || 0;
|
||||
thisExpense.value = result[0]?.expenseAmount || 0;
|
||||
thisShare.value = result[0]?.expenseRatio || 0;
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const result = await erpAccountListAll();
|
||||
listAccount.value = result || [];
|
||||
} catch (error) {
|
||||
console.error('获取账号列表失败:', error);
|
||||
listAccount.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 图例选中事件处理
|
||||
const handleLegendSelect = (params: any) => {
|
||||
const { name, selected } = params;
|
||||
|
||||
if (selected[name]) {
|
||||
selectedLegends.value.add(name);
|
||||
} else {
|
||||
selectedLegends.value.delete(name);
|
||||
}
|
||||
|
||||
isAllUnselected.value = selectedLegends.value.size === 0 && allLegendNames.value.size > 0;
|
||||
// 同步更新图表数据
|
||||
myChart?.setOption({
|
||||
series: [
|
||||
{
|
||||
name: '账户金额',
|
||||
data: listAccount.value.map(item => ({
|
||||
name: item.accountName || '未知账户',
|
||||
value: Number(item.currentBalance) || 0
|
||||
}))
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化图表(图例调整到最右侧)
|
||||
const initChart = () => {
|
||||
if (!chartDom.value || listAccount.value.length === 0) return;
|
||||
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
const pieData = listAccount.value.map(item => ({
|
||||
name: item.accountName || '未知账户',
|
||||
value: Number(item.currentBalance) || 0
|
||||
}));
|
||||
|
||||
const validData = pieData.filter(item => item.value > 0);
|
||||
const colors = generateRandomColors(validData.length);
|
||||
|
||||
allLegendNames.value = new Set(validData.map(item => item.name));
|
||||
const total = validData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
const option: EChartsOption = {
|
||||
title: {
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: { fontSize: 16, color: '#333', fontWeight: 500 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 元 ({d}%)',
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
// 图例配置调整到最右侧
|
||||
legend: {
|
||||
orient: 'vertical', // 垂直排列
|
||||
right: 10, // 右侧距离
|
||||
top: 'center', // 垂直居中
|
||||
textStyle: { fontSize: 12, color: '#666' },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
itemGap: 15, // 图例项间距
|
||||
padding: [20, 10, 20, 10], // 内边距:上右下左
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '账户金额',
|
||||
type: 'pie',
|
||||
radius: ['35%', '70%'], // 调整饼图半径,给右侧图例留出空间
|
||||
center: ['40%', '50%'], // 饼图位置左移,适配右侧图例
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
fontSize: 11,
|
||||
formatter: (params: any) => {
|
||||
return `${params.name} ${formatPercent((params.value / total) * 100)}`;
|
||||
},
|
||||
color: '#333',
|
||||
distance: 20,
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 20,
|
||||
length2: 15,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#999'
|
||||
},
|
||||
smooth: 0.2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)'
|
||||
}
|
||||
},
|
||||
data: validData.map((item, index) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
itemStyle: { color: colors[index] }
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
myChart.on('legendselectchanged', handleLegendSelect);
|
||||
|
||||
// 初始化选中状态
|
||||
selectedLegends.value = new Set(validData.map(item => item.name));
|
||||
isAllUnselected.value = false;
|
||||
};
|
||||
|
||||
const resizeChart = () => {
|
||||
myChart?.resize();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchList();
|
||||
await getThisData();
|
||||
// 增加微延迟确保DOM渲染完成
|
||||
setTimeout(() => {
|
||||
initChart();
|
||||
}, 100);
|
||||
window.addEventListener('resize', resizeChart);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeChart);
|
||||
if (myChart) {
|
||||
myChart.off('legendselectchanged', handleLegendSelect);
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
selectedLegends.value.clear();
|
||||
allLegendNames.value.clear();
|
||||
isAllUnselected.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 核心布局样式 */
|
||||
.layout-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 左侧20%面板 - 精简版 */
|
||||
.left-panel {
|
||||
width: 15%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; /* 缩小卡片间距 */
|
||||
}
|
||||
|
||||
/* 统计卡片样式 - 更小尺寸 */
|
||||
.stat-card {
|
||||
background-color: #f0f7ff; /* 淡蓝色背景 */
|
||||
border-radius: 6px;
|
||||
padding: 10px 8px; /* 缩小内边距 */
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e6f4ff;
|
||||
}
|
||||
|
||||
/* 统计标题 - 简化为span */
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px; /* 缩小间距 */
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* 统计内容(图标+金额)- 关键优化 */
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* 改为两端对齐 */
|
||||
width: 100%; /* 确保占满容器宽度 */
|
||||
padding: 0 4px; /* 增加左右内边距 */
|
||||
}
|
||||
|
||||
/* 图标容器 - 确保图标靠左对齐 */
|
||||
:deep(.stat-content .icon) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0; /* 防止图标被压缩 */
|
||||
}
|
||||
|
||||
/* 统计数值 - 确保数字靠右对齐 */
|
||||
.stat-value {
|
||||
font-size: 12px; /* 缩小字体 */
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
text-align: right; /* 文字右对齐 */
|
||||
flex-shrink: 0; /* 防止数字被压缩 */
|
||||
}
|
||||
|
||||
/* 右侧80%图表面板 */
|
||||
.right-panel {
|
||||
width: 85%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
:deep(.left-panel::-webkit-scrollbar) {
|
||||
width: 4px; /* 缩小滚动条 */
|
||||
}
|
||||
|
||||
:deep(.left-panel::-webkit-scrollbar-thumb) {
|
||||
background: #d9d9d9;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:deep(.left-panel::-webkit-scrollbar-track) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 图表样式优化 - 适配右侧图例 */
|
||||
:deep(.ant-card .echarts-legend-item) {
|
||||
margin: 5px 0 !important;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.echarts-legend-text) {
|
||||
margin-left: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.echarts-text) {
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
:deep(.echarts-tooltip) {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.echarts-empty) {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 响应式适配 - 移动端图例调整 */
|
||||
@media (max-width: 768px) {
|
||||
.layout-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.left-panel, .right-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex-direction: row;
|
||||
height: 140px; /* 缩小高度 */
|
||||
overflow-x: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-width: 120px; /* 缩小最小宽度 */
|
||||
flex: none;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
height: 300px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 移动端保持图标左、数字右对齐 */
|
||||
.stat-content {
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* 移动端图例改回底部水平排列 */
|
||||
:deep(.echarts-legend) {
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
bottom: 5px !important;
|
||||
left: 'center' !important;
|
||||
orient: 'horizontal' !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<PageWrapper class="dashboard-container">
|
||||
<div class="top-header-section">
|
||||
<div class="header-icon-wrapper">
|
||||
<img :src="headerImg" class="header-img" />
|
||||
<span class="icon-text">财务可视化指标统计</span>
|
||||
</div>
|
||||
<BasicForm
|
||||
:labelWidth="100"
|
||||
:schemas="schemaForm.schemas"
|
||||
class="search-form"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-layout">
|
||||
<div class="two-column-item">
|
||||
<ChartPie />
|
||||
<ChartLineRatio :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="two-column-item">
|
||||
<ChartLineInc :formParams="FormValues" />
|
||||
<ChartLineExp :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="single-chart-item">
|
||||
<ChartLineType :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="two-column-item">
|
||||
<ChartBarAccount :formParams="FormValues" />
|
||||
<ChartBarCycle :formParams="FormValues" />
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { BasicForm, FormProps } from '@jeesite/core/components/Form';
|
||||
import { PageWrapper } from '@jeesite/core/components/Page';
|
||||
|
||||
// 图表组件导入
|
||||
import ChartPie from './components/ChartPie.vue';
|
||||
import ChartLineExp from './components/ChartLineExp.vue';
|
||||
import ChartLineInc from './components/ChartLineInc.vue';
|
||||
import ChartBarCycle from './components/ChartBarCycle.vue';
|
||||
import ChartBarAccount from './components/ChartBarAccount.vue';
|
||||
import ChartLineType from './components/ChartLineType.vue';
|
||||
import ChartLineRatio from './components/ChartLineRatio.vue';
|
||||
|
||||
// 静态资源
|
||||
import headerImg from '@jeesite/assets/images/bigview.png';
|
||||
|
||||
// 表单数据
|
||||
const FormValues = ref<Record<string, any>>({
|
||||
cycleType: 'M'
|
||||
});
|
||||
|
||||
// 表单配置
|
||||
const schemaForm: FormProps = {
|
||||
baseColProps: { md: 8, lg: 6 },
|
||||
labelWidth: 90,
|
||||
schemas: [
|
||||
{
|
||||
label: '周期',
|
||||
field: 'cycleType',
|
||||
defaultValue: 'M',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
dictType: 'report_cycle',
|
||||
allowClear: true,
|
||||
onChange: (value: string) => {
|
||||
FormValues.value.cycleType = value || '';
|
||||
}
|
||||
},
|
||||
colProps: { md: 24, lg: 24 },
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 0 8px;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 顶部头部样式 */
|
||||
.top-header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
background-color: #e6f7ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #bde5ff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 图标容器样式 */
|
||||
.header-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 头部图片样式 */
|
||||
.header-img {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 图标文本样式 */
|
||||
.icon-text {
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.search-form {
|
||||
width: 245px;
|
||||
}
|
||||
|
||||
/* 图表布局容器 */
|
||||
.chart-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 双列图表项 */
|
||||
.two-column-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 单列图表项 */
|
||||
.single-chart-item {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 通用图表容器样式 - 强制子组件占满容器 */
|
||||
.two-column-item > *,
|
||||
.single-chart-item > * {
|
||||
flex: 1;
|
||||
min-width: 0; /* 解决flex子元素宽度溢出问题 */
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.top-header-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-img {
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-icon-wrapper {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.two-column-item {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-layout {
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.single-chart-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover效果 */
|
||||
.header-icon-wrapper:hover .header-img {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* 防止图片溢出 */
|
||||
.header-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
|
||||
const { adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpAccountIncomeExpense extends BasicModel<ErpAccountIncomeExpense> {
|
||||
accountId: string; // 交易账户
|
||||
yearDate?: string; // year_date
|
||||
incomeAmount?: number; // income_amount
|
||||
expenseAmount?: number; // expense_amount
|
||||
}
|
||||
|
||||
export const erpAccountIncomeExpenseList = (params?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.get<ErpAccountIncomeExpense>({ url: adminPath + '/erp/accountIncomeExpense/list', params });
|
||||
|
||||
export const erpAccountIncomeExpenseListAll = (params?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.get<ErpAccountIncomeExpense[]>({ url: adminPath + '/erp/accountIncomeExpense/listAll', params });
|
||||
|
||||
export const erpAccountIncomeExpenseListData = (params?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.post<Page<ErpAccountIncomeExpense>>({ url: adminPath + '/erp/accountIncomeExpense/listData', params });
|
||||
|
||||
export const erpAccountIncomeExpenseForm = (params?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.get<ErpAccountIncomeExpense>({ url: adminPath + '/erp/accountIncomeExpense/form', params });
|
||||
|
||||
export const erpAccountIncomeExpenseSave = (params?: any, data?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.postJson<ErpAccountIncomeExpense>({ url: adminPath + '/erp/accountIncomeExpense/save', params, data });
|
||||
|
||||
export const erpAccountIncomeExpenseDelete = (params?: ErpAccountIncomeExpense | any) =>
|
||||
defHttp.get<ErpAccountIncomeExpense>({ url: adminPath + '/erp/accountIncomeExpense/delete', params });
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
|
||||
const { adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpCategoryFlow extends BasicModel<ErpCategoryFlow> {
|
||||
cycleType: string; // cycle_type
|
||||
categoryName: string; // 分类名称
|
||||
statDate?: string; // stat_date
|
||||
thisIncome: number; // this_income
|
||||
prevIncome: number; // prev_income
|
||||
incomeMom?: number; // income_mom
|
||||
thisExpense: number; // this_expense
|
||||
prevExpense: number; // prev_expense
|
||||
expenseMom?: number; // expense_mom
|
||||
}
|
||||
|
||||
export const erpCategoryFlowList = (params?: ErpCategoryFlow | any) =>
|
||||
defHttp.get<ErpCategoryFlow>({ url: adminPath + '/erp/categoryFlow/list', params });
|
||||
|
||||
export const erpCategoryFlowListAll = (params?: ErpCategoryFlow | any) =>
|
||||
defHttp.get<ErpCategoryFlow[]>({ url: adminPath + '/erp/categoryFlow/listAll', params });
|
||||
|
||||
export const erpCategoryFlowListData = (params?: ErpCategoryFlow | any) =>
|
||||
defHttp.post<Page<ErpCategoryFlow>>({ url: adminPath + '/erp/categoryFlow/listData', params });
|
||||
|
||||
export const erpCategoryFlowForm = (params?: ErpCategoryFlow | any) =>
|
||||
defHttp.get<ErpCategoryFlow>({ url: adminPath + '/erp/categoryFlow/form', params });
|
||||
|
||||
export const erpCategoryFlowSave = (params?: any, data?: ErpCategoryFlow | any) =>
|
||||
defHttp.postJson<ErpCategoryFlow>({ url: adminPath + '/erp/categoryFlow/save', params, data });
|
||||
|
||||
export const erpCategoryFlowDelete = (params?: ErpCategoryFlow | any) =>
|
||||
defHttp.get<ErpCategoryFlow>({ url: adminPath + '/erp/categoryFlow/delete', params });
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
|
||||
const { adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpCategoryIncomeExpense extends BasicModel<ErpCategoryIncomeExpense> {
|
||||
categoryId: string; // 交易分类
|
||||
yearDate?: string; // year_date
|
||||
incomeAmount?: number; // income_amount
|
||||
expenseAmount?: number; // expense_amount
|
||||
}
|
||||
|
||||
export const erpCategoryIncomeExpenseList = (params?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.get<ErpCategoryIncomeExpense>({ url: adminPath + '/erp/categoryIncomeExpense/list', params });
|
||||
|
||||
export const erpCategoryIncomeExpenseListAll = (params?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.get<ErpCategoryIncomeExpense[]>({ url: adminPath + '/erp/categoryIncomeExpense/listAll', params });
|
||||
|
||||
export const erpCategoryIncomeExpenseListData = (params?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.post<Page<ErpCategoryIncomeExpense>>({ url: adminPath + '/erp/categoryIncomeExpense/listData', params });
|
||||
|
||||
export const erpCategoryIncomeExpenseForm = (params?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.get<ErpCategoryIncomeExpense>({ url: adminPath + '/erp/categoryIncomeExpense/form', params });
|
||||
|
||||
export const erpCategoryIncomeExpenseSave = (params?: any, data?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.postJson<ErpCategoryIncomeExpense>({ url: adminPath + '/erp/categoryIncomeExpense/save', params, data });
|
||||
|
||||
export const erpCategoryIncomeExpenseDelete = (params?: ErpCategoryIncomeExpense | any) =>
|
||||
defHttp.get<ErpCategoryIncomeExpense>({ url: adminPath + '/erp/categoryIncomeExpense/delete', params });
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
|
||||
const { adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpExpInc extends BasicModel<ErpExpInc> {
|
||||
createTime?: string; // 记录时间
|
||||
accountName?: string; // account_name
|
||||
statDate?: string; // stat_date
|
||||
cycleType?: string; // cycle_type
|
||||
incomeAmount?: number; // income_amount
|
||||
expenseAmount?: number; // expense_amount
|
||||
}
|
||||
|
||||
export const erpExpIncList = (params?: ErpExpInc | any) =>
|
||||
defHttp.get<ErpExpInc>({ url: adminPath + '/erp/expInc/list', params });
|
||||
|
||||
export const erpExpIncListAll = (params?: ErpExpInc | any) =>
|
||||
defHttp.get<ErpExpInc[]>({ url: adminPath + '/erp/expInc/listAll', params });
|
||||
|
||||
export const erpExpIncListData = (params?: ErpExpInc | any) =>
|
||||
defHttp.post<Page<ErpExpInc>>({ url: adminPath + '/erp/expInc/listData', params });
|
||||
|
||||
export const erpExpIncForm = (params?: ErpExpInc | any) =>
|
||||
defHttp.get<ErpExpInc>({ url: adminPath + '/erp/expInc/form', params });
|
||||
|
||||
export const erpExpIncSave = (params?: any, data?: ErpExpInc | any) =>
|
||||
defHttp.postJson<ErpExpInc>({ url: adminPath + '/erp/expInc/save', params, data });
|
||||
|
||||
export const erpExpIncDelete = (params?: ErpExpInc | any) =>
|
||||
defHttp.get<ErpExpInc>({ url: adminPath + '/erp/expInc/delete', params });
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
|
||||
const { adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpIncExpRatio extends BasicModel<ErpIncExpRatio> {
|
||||
cycleType: string; // cycle_type
|
||||
statDate?: string; // stat_date
|
||||
incomeAmount?: number; // income_amount
|
||||
expenseAmount?: number; // expense_amount
|
||||
expenseRatio: number; // expense_ratio
|
||||
}
|
||||
|
||||
export const erpIncExpRatioList = (params?: ErpIncExpRatio | any) =>
|
||||
defHttp.get<ErpIncExpRatio>({ url: adminPath + '/erp/incExpRatio/list', params });
|
||||
|
||||
export const erpIncExpRatioListAll = (params?: ErpIncExpRatio | any) =>
|
||||
defHttp.get<ErpIncExpRatio[]>({ url: adminPath + '/erp/incExpRatio/listAll', params });
|
||||
|
||||
export const erpIncExpRatioListData = (params?: ErpIncExpRatio | any) =>
|
||||
defHttp.post<Page<ErpIncExpRatio>>({ url: adminPath + '/erp/incExpRatio/listData', params });
|
||||
|
||||
export const erpIncExpRatioForm = (params?: ErpIncExpRatio | any) =>
|
||||
defHttp.get<ErpIncExpRatio>({ url: adminPath + '/erp/incExpRatio/form', params });
|
||||
|
||||
export const erpIncExpRatioSave = (params?: any, data?: ErpIncExpRatio | any) =>
|
||||
defHttp.postJson<ErpIncExpRatio>({ url: adminPath + '/erp/incExpRatio/save', params, data });
|
||||
|
||||
export const erpIncExpRatioDelete = (params?: ErpIncExpRatio | any) =>
|
||||
defHttp.get<ErpIncExpRatio>({ url: adminPath + '/erp/incExpRatio/delete', params });
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
import { UploadApiResult } from '@jeesite/core/api/sys/upload';
|
||||
import { UploadFileParams } from '@jeesite/types/axios';
|
||||
import { AxiosProgressEvent } from 'axios';
|
||||
|
||||
const { ctxPath, adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpIncomeExpenseCycle extends BasicModel<ErpIncomeExpenseCycle> {
|
||||
yearDate?: string; // year_date
|
||||
monthDate?: string; // month_date
|
||||
incomeAmount?: number; // income_amount
|
||||
expenseAmount?: number; // expense_amount
|
||||
}
|
||||
|
||||
export const erpIncomeExpenseCycleList = (params?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.get<ErpIncomeExpenseCycle>({ url: adminPath + '/erp/incomeExpenseCycle/list', params });
|
||||
|
||||
export const erpIncomeExpenseCycleListAll = (params?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.get<ErpIncomeExpenseCycle[]>({ url: adminPath + '/erp/incomeExpenseCycle/listAll', params });
|
||||
|
||||
export const erpIncomeExpenseCycleListData = (params?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.post<Page<ErpIncomeExpenseCycle>>({ url: adminPath + '/erp/incomeExpenseCycle/listData', params });
|
||||
|
||||
export const erpIncomeExpenseCycleForm = (params?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.get<ErpIncomeExpenseCycle>({ url: adminPath + '/erp/incomeExpenseCycle/form', params });
|
||||
|
||||
export const erpIncomeExpenseCycleSave = (params?: any, data?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.postJson<ErpIncomeExpenseCycle>({ url: adminPath + '/erp/incomeExpenseCycle/save', params, data });
|
||||
|
||||
export const erpIncomeExpenseCycleImportData = (
|
||||
params: UploadFileParams,
|
||||
onUploadProgress: (progressEvent: AxiosProgressEvent) => void,
|
||||
) =>
|
||||
defHttp.uploadFile<UploadApiResult>(
|
||||
{
|
||||
url: ctxPath + adminPath + '/erp/incomeExpenseCycle/importData',
|
||||
onUploadProgress,
|
||||
},
|
||||
params,
|
||||
);
|
||||
|
||||
export const erpIncomeExpenseCycleDelete = (params?: ErpIncomeExpenseCycle | any) =>
|
||||
defHttp.get<ErpIncomeExpenseCycle>({ url: adminPath + '/erp/incomeExpenseCycle/delete', params });
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2013-Now http://jeesite.com All rights reserved.
|
||||
* No deletion without permission, or be held responsible to law.
|
||||
* @author gaoxq
|
||||
*/
|
||||
import { defHttp } from '@jeesite/core/utils/http/axios';
|
||||
import { useGlobSetting } from '@jeesite/core/hooks/setting';
|
||||
import { BasicModel, Page } from '@jeesite/core/api/model/baseModel';
|
||||
import { UploadApiResult } from '@jeesite/core/api/sys/upload';
|
||||
import { UploadFileParams } from '@jeesite/types/axios';
|
||||
import { AxiosProgressEvent } from 'axios';
|
||||
|
||||
const { ctxPath, adminPath } = useGlobSetting();
|
||||
|
||||
export interface ErpSummaryAll extends BasicModel<ErpSummaryAll> {
|
||||
createTime?: string; // 记录时间
|
||||
cdate?: string; // 汇总日期
|
||||
ctype: string; // 交易类型
|
||||
thisValue?: number; // 当期金额
|
||||
prevValue?: number; // 上期金额
|
||||
momRate?: number; // 环比
|
||||
fcycle: string; // 周期类型
|
||||
}
|
||||
|
||||
export const erpSummaryAllList = (params?: ErpSummaryAll | any) =>
|
||||
defHttp.get<ErpSummaryAll>({ url: adminPath + '/erp/summaryAll/list', params });
|
||||
|
||||
export const erpSummaryAllListAll = (params?: ErpSummaryAll | any) =>
|
||||
defHttp.get<ErpSummaryAll[]>({ url: adminPath + '/erp/summaryAll/listAll', params });
|
||||
|
||||
export const erpSummaryAllListData = (params?: ErpSummaryAll | any) =>
|
||||
defHttp.post<Page<ErpSummaryAll>>({ url: adminPath + '/erp/summaryAll/listData', params });
|
||||
|
||||
export const erpSummaryAllForm = (params?: ErpSummaryAll | any) =>
|
||||
defHttp.get<ErpSummaryAll>({ url: adminPath + '/erp/summaryAll/form', params });
|
||||
|
||||
export const erpSummaryAllSave = (params?: any, data?: ErpSummaryAll | any) =>
|
||||
defHttp.postJson<ErpSummaryAll>({ url: adminPath + '/erp/summaryAll/save', params, data });
|
||||
|
||||
export const erpSummaryAllImportData = (
|
||||
params: UploadFileParams,
|
||||
onUploadProgress: (progressEvent: AxiosProgressEvent) => void,
|
||||
) =>
|
||||
defHttp.uploadFile<UploadApiResult>(
|
||||
{
|
||||
url: ctxPath + adminPath + '/erp/summaryAll/importData',
|
||||
onUploadProgress,
|
||||
},
|
||||
params,
|
||||
);
|
||||
|
||||
export const erpSummaryAllDelete = (params?: ErpSummaryAll | any) =>
|
||||
defHttp.get<ErpSummaryAll>({ url: adminPath + '/erp/summaryAll/delete', params });
|
||||
@@ -1,67 +1,38 @@
|
||||
<template>
|
||||
<Card title="年度账户收支情况" style="width: 100%; height: 100%;">
|
||||
<template #extra>
|
||||
<div class="status-filter-container">
|
||||
<div class="status-filter">
|
||||
<span
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="['status-item', { active: currentStatus === item.value }]"
|
||||
@click="handleStatusChange(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 { ref, onMounted, onUnmounted, watch, watchEffect } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useMessage } from '@jeesite/core/hooks/web/useMessage';
|
||||
import { erpAccountIncomeExpenseListAll } from '@jeesite/erp/api/erp/accountIncomeExpense';
|
||||
import { BizItemInfo, bizItemInfoListAll } from '@jeesite/biz/api/biz/itemInfo';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const statusOptions = ref([
|
||||
{ value: String(currentYear), label: '今年' },
|
||||
{ value: String(currentYear - 1), label: '去年' },
|
||||
]);
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const currentStatus = ref<string>(String(currentYear));
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
if (currentStatus.value === status) return;
|
||||
currentStatus.value = status;
|
||||
fetchList();
|
||||
};
|
||||
|
||||
interface ErpIncExpRatio {
|
||||
incomeAmount?: number | string;
|
||||
expenseAmount?: number | string;
|
||||
accountName?: string;
|
||||
}
|
||||
|
||||
const rawData = ref<ErpIncExpRatio[]>([]);
|
||||
const rawData = ref<BizItemInfo[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
let resizeTimer: number | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
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));
|
||||
return isNaN(parsed) ? 0 : Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const toTenThousandYuan = (num: string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
return formatNumber((rawNum / 10000).toString());
|
||||
};
|
||||
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const formatWithThousandsSeparator = (num: string | number | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
@@ -70,90 +41,82 @@ const formatWithThousandsSeparator = (num: number | string | undefined): string
|
||||
});
|
||||
};
|
||||
|
||||
const calculateNetProfit = (income: number | string | undefined, expense: number | string | undefined): number => {
|
||||
const calculateNetProfit = (income: string | undefined, expense: string | undefined): number => {
|
||||
const incomeNum = formatNumber(income);
|
||||
const expenseNum = formatNumber(expense);
|
||||
return formatNumber(incomeNum - expenseNum);
|
||||
return formatNumber((incomeNum - expenseNum).toString());
|
||||
};
|
||||
|
||||
const sortByCategory = (data: ErpIncExpRatio[]): ErpIncExpRatio[] => {
|
||||
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 nameA = a.accountName || '';
|
||||
const nameB = b.accountName || '';
|
||||
return nameA.localeCompare(nameB, 'zh-CN');
|
||||
const monthA = a.xaxis ? parseInt(a.xaxis, 10) : 0;
|
||||
const monthB = b.xaxis ? parseInt(b.xaxis, 10) : 0;
|
||||
return monthA - monthB;
|
||||
});
|
||||
};
|
||||
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const params = {
|
||||
yearDate: currentStatus.value
|
||||
}
|
||||
const result = await erpAccountIncomeExpenseListAll(params);
|
||||
const validData = (result || [])
|
||||
.filter(item => item.accountName)
|
||||
.map(item => ({
|
||||
monthDate: item.monthDate,
|
||||
accountName: item.accountName,
|
||||
incomeAmount: formatNumber(item.incomeAmount),
|
||||
expenseAmount: formatNumber(item.expenseAmount)
|
||||
})) as ErpIncExpRatio[];
|
||||
rawData.value = sortByCategory(validData);
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
rawData.value = [];
|
||||
} finally {
|
||||
initChart();
|
||||
}
|
||||
};
|
||||
|
||||
const calculateYAxisExtent = (data: number[]) => {
|
||||
if (data.length === 0) return [0, 10];
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
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;
|
||||
|
||||
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 (myChart) {
|
||||
myChart.dispose();
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartDom.value);
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
if (rawData.value.length === 0) {
|
||||
myChart.setOption({
|
||||
chartInstance.setOption({
|
||||
title: {
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 18, color: '#333' }
|
||||
},
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['收入', '支出'], top: 10, textStyle: { fontSize: 12 } },
|
||||
legend: { data: ['收入', '支出', '支出占比'], top: 10, textStyle: { fontSize: 12 } },
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
@@ -184,12 +147,27 @@ const initChart = () => {
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
formatter: (value: number) => value.toFixed(2)
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
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: [],
|
||||
@@ -199,17 +177,31 @@ const initChart = () => {
|
||||
position: 'center',
|
||||
effect: 'none'
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const xAxisData = rawData.value.map(item => item.accountName || '').filter(Boolean);
|
||||
const incomeData = rawData.value.map(item => toTenThousandYuan(item.incomeAmount));
|
||||
const expenseData = rawData.value.map(item => toTenThousandYuan(item.expenseAmount));
|
||||
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',
|
||||
@@ -225,30 +217,29 @@ const initChart = () => {
|
||||
borderWidth: 1,
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '<div style="padding: 8px;">暂无数据</div>';
|
||||
|
||||
const currentCategory = params[0]?.axisValue || '';
|
||||
const item = rawData.value.find(i => i.accountName === currentCategory);
|
||||
|
||||
if (!item) return `<div style="padding: 8px;">${currentCategory}:暂无明细</div>`;
|
||||
|
||||
const netProfit = calculateNetProfit(item.incomeAmount, item.expenseAmount);
|
||||
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;">${currentCategory}</div>
|
||||
<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.incomeAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #f5222d;">${formatWithThousandsSeparator(item.expenseAmount)} 元</td>
|
||||
<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>
|
||||
@@ -256,14 +247,14 @@ const initChart = () => {
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
data: ['收入', '支出', '支出占比'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 11 }
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
right: 50,
|
||||
bottom: 60,
|
||||
top: 40,
|
||||
containLabel: true
|
||||
@@ -293,7 +284,7 @@ const initChart = () => {
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
formatter: (value: number) => value.toFixed(2)
|
||||
},
|
||||
min: amountMin,
|
||||
max: amountMax,
|
||||
@@ -301,7 +292,26 @@ const initChart = () => {
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
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: {
|
||||
@@ -318,7 +328,7 @@ const initChart = () => {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 0, color: '#096dd9' }
|
||||
{ offset: 1, color: '#096dd9' }
|
||||
]),
|
||||
borderRadius: [6, 6, 0, 0]
|
||||
},
|
||||
@@ -354,22 +364,59 @@ const initChart = () => {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#ff4d4f' },
|
||||
{ offset: 1, color: '#f5222d' }
|
||||
{ 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'
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
chartInstance.setOption(option, true);
|
||||
};
|
||||
|
||||
const resizeChart = () => {
|
||||
if (myChart) {
|
||||
myChart.resize({
|
||||
if (chartInstance) {
|
||||
chartInstance.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'quadraticInOut'
|
||||
@@ -378,28 +425,40 @@ const resizeChart = () => {
|
||||
}
|
||||
};
|
||||
|
||||
let resizeTimer: number;
|
||||
const debounceResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resizeChart, 100);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchList();
|
||||
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;
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(rawData, () => {
|
||||
initChart();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -472,11 +531,6 @@ watch(rawData, () => {
|
||||
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;
|
||||
@@ -494,19 +548,4 @@ watch(rawData, () => {
|
||||
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>
|
||||
@@ -1,512 +0,0 @@
|
||||
<template>
|
||||
<Card title="年度分类收支情况" style="width: 100%; height: 100%;">
|
||||
<template #extra>
|
||||
<div class="status-filter-container">
|
||||
<div class="status-filter">
|
||||
<span
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="['status-item', { active: currentStatus === item.value }]"
|
||||
@click="handleStatusChange(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<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 { erpCategoryIncomeExpenseListAll } from '@jeesite/erp/api/erp/categoryIncomeExpense';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const statusOptions = ref([
|
||||
{ value: String(currentYear), label: '今年' },
|
||||
{ value: String(currentYear - 1), label: '去年' },
|
||||
]);
|
||||
|
||||
const currentStatus = ref<string>(String(currentYear));
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
if (currentStatus.value === status) return;
|
||||
currentStatus.value = status;
|
||||
fetchList();
|
||||
};
|
||||
|
||||
interface ErpIncExpRatio {
|
||||
incomeAmount?: number | string;
|
||||
expenseAmount?: number | string;
|
||||
categoryName?: string;
|
||||
}
|
||||
|
||||
const rawData = ref<ErpIncExpRatio[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: echarts.ECharts | null = null;
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const formatNumber = (num: number | string | undefined, decimal = 2): number => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return 0;
|
||||
return Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
};
|
||||
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
};
|
||||
|
||||
const calculateNetProfit = (income: number | string | undefined, expense: number | string | undefined): number => {
|
||||
const incomeNum = formatNumber(income);
|
||||
const expenseNum = formatNumber(expense);
|
||||
return formatNumber(incomeNum - expenseNum);
|
||||
};
|
||||
|
||||
const sortByCategory = (data: ErpIncExpRatio[]): ErpIncExpRatio[] => {
|
||||
return data.sort((a, b) => {
|
||||
const nameA = a.categoryName || '';
|
||||
const nameB = b.categoryName || '';
|
||||
return nameA.localeCompare(nameB, 'zh-CN');
|
||||
});
|
||||
};
|
||||
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const params = {
|
||||
yearDate: currentStatus.value
|
||||
}
|
||||
const result = await erpCategoryIncomeExpenseListAll(params);
|
||||
const validData = (result || [])
|
||||
.filter(item => item.categoryName)
|
||||
.map(item => ({
|
||||
monthDate: item.monthDate,
|
||||
categoryName: item.categoryName,
|
||||
incomeAmount: formatNumber(item.incomeAmount),
|
||||
expenseAmount: formatNumber(item.expenseAmount)
|
||||
})) as ErpIncExpRatio[];
|
||||
rawData.value = sortByCategory(validData);
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
rawData.value = [];
|
||||
} finally {
|
||||
initChart();
|
||||
}
|
||||
};
|
||||
|
||||
const calculateYAxisExtent = (data: number[]) => {
|
||||
if (data.length === 0) return [0, 10];
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
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: 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) => formatNumber(value).toFixed(2)
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
}
|
||||
],
|
||||
series: [],
|
||||
noDataLoadingOption: {
|
||||
text: '暂无收入支出数据',
|
||||
textStyle: { fontSize: 16, color: '#666', fontWeight: '500' },
|
||||
position: 'center',
|
||||
effect: 'none'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const xAxisData = rawData.value.map(item => item.categoryName || '').filter(Boolean);
|
||||
const incomeData = rawData.value.map(item => toTenThousandYuan(item.incomeAmount));
|
||||
const expenseData = rawData.value.map(item => toTenThousandYuan(item.expenseAmount));
|
||||
|
||||
const amountData = [...incomeData, ...expenseData];
|
||||
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
|
||||
|
||||
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 currentCategory = params[0]?.axisValue || '';
|
||||
const item = rawData.value.find(i => i.categoryName === currentCategory);
|
||||
|
||||
if (!item) return `<div style="padding: 8px;">${currentCategory}:暂无明细</div>`;
|
||||
|
||||
const netProfit = calculateNetProfit(item.incomeAmount, item.expenseAmount);
|
||||
const netProfitColor = netProfit > 0 ? '#52c41a' : netProfit < 0 ? '#f5222d' : '#666';
|
||||
|
||||
return `
|
||||
<div style="font-weight: 600; color: #333; margin-bottom: 8px; text-align: center;">${currentCategory}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #1890ff;">${formatWithThousandsSeparator(item.incomeAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #f5222d;">${formatWithThousandsSeparator(item.expenseAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: ${netProfitColor};">${formatWithThousandsSeparator(netProfit)} 元</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 11 }
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
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) => formatNumber(value).toFixed(2)
|
||||
},
|
||||
min: amountMin,
|
||||
max: amountMax,
|
||||
axisLine: { onZero: true },
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
}
|
||||
],
|
||||
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' }
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
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: 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>
|
||||
@@ -1,67 +1,38 @@
|
||||
<template>
|
||||
<Card title="年度各月收支情况" style="width: 100%; height: 100%;">
|
||||
<template #extra>
|
||||
<div class="status-filter-container">
|
||||
<div class="status-filter">
|
||||
<span
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="['status-item', { active: currentStatus === item.value }]"
|
||||
@click="handleStatusChange(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 { ref, onMounted, onUnmounted, watch, watchEffect } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useMessage } from '@jeesite/core/hooks/web/useMessage';
|
||||
import { erpIncomeExpenseCycleListAll } from '@jeesite/erp/api/erp/incomeExpenseCycle';
|
||||
import { BizItemInfo, bizItemInfoListAll } from '@jeesite/biz/api/biz/itemInfo';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const statusOptions = ref([
|
||||
{ value: String(currentYear), label: '今年' },
|
||||
{ value: String(currentYear - 1), label: '去年' },
|
||||
]);
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const currentStatus = ref<string>(String(currentYear));
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
if (currentStatus.value === status) return;
|
||||
currentStatus.value = status;
|
||||
fetchList();
|
||||
};
|
||||
|
||||
interface ErpIncExpRatio {
|
||||
monthDate?: string;
|
||||
incomeAmount?: number | string;
|
||||
expenseAmount?: number | string;
|
||||
}
|
||||
|
||||
const rawData = ref<ErpIncExpRatio[]>([]);
|
||||
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: number | string | undefined, decimal = 2): number => {
|
||||
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));
|
||||
return isNaN(parsed) ? 0 : Number(parsed.toFixed(decimal));
|
||||
};
|
||||
|
||||
const toTenThousandYuan = (num: number | string | undefined): number => {
|
||||
const toTenThousandYuan = (num: string | undefined): number => {
|
||||
const rawNum = formatNumber(num);
|
||||
return formatNumber(rawNum / 10000);
|
||||
return formatNumber((rawNum / 10000).toString());
|
||||
};
|
||||
|
||||
const formatWithThousandsSeparator = (num: number | string | undefined): string => {
|
||||
const formatWithThousandsSeparator = (num: string | number | undefined): string => {
|
||||
const parsed = Number(num);
|
||||
if (isNaN(parsed)) return '0.00';
|
||||
return parsed.toLocaleString('zh-CN', {
|
||||
@@ -70,85 +41,78 @@ const formatWithThousandsSeparator = (num: number | string | undefined): string
|
||||
});
|
||||
};
|
||||
|
||||
const calculateNetProfit = (income: number | string | undefined, expense: number | string | undefined): number => {
|
||||
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);
|
||||
return formatNumber((incomeNum - expenseNum).toString());
|
||||
};
|
||||
|
||||
const formatDate = (date: string | undefined): string => {
|
||||
if (!date) return '';
|
||||
return date.replace(/\//g, '-').trim();
|
||||
const formatMonth = (xaxis: string | undefined): string => {
|
||||
if (!xaxis) return '';
|
||||
const monthNum = parseInt(xaxis, 10);
|
||||
return isNaN(monthNum) ? xaxis : `${monthNum}月`;
|
||||
};
|
||||
|
||||
const sortDates = (data: ErpIncExpRatio[]): ErpIncExpRatio[] => {
|
||||
const sortByMonth = (data: BizItemInfo[]): BizItemInfo[] => {
|
||||
return data.sort((a, b) => {
|
||||
const dateA = new Date(a.monthDate || '');
|
||||
const dateB = new Date(b.monthDate || '');
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
const monthA = a.xaxis ? parseInt(a.xaxis, 10) : 0;
|
||||
const monthB = b.xaxis ? parseInt(b.xaxis, 10) : 0;
|
||||
return monthA - monthB;
|
||||
});
|
||||
};
|
||||
|
||||
const barLabelConfig = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 3,
|
||||
textStyle: {
|
||||
fontSize: 10,
|
||||
color: '#333',
|
||||
fontWeight: '500'
|
||||
},
|
||||
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
|
||||
};
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const params = {
|
||||
yearDate: currentStatus.value
|
||||
}
|
||||
const result = await erpIncomeExpenseCycleListAll(params);
|
||||
const validData = (result || [])
|
||||
.filter(item => item.monthDate)
|
||||
.map(item => ({
|
||||
monthDate: formatDate(item.monthDate),
|
||||
incomeAmount: formatNumber(item.incomeAmount),
|
||||
expenseAmount: formatNumber(item.expenseAmount)
|
||||
})) as ErpIncExpRatio[];
|
||||
rawData.value = sortDates(validData);
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
rawData.value = [];
|
||||
} finally {
|
||||
initChart();
|
||||
}
|
||||
};
|
||||
|
||||
const calculateYAxisExtent = (data: number[]) => {
|
||||
if (data.length === 0) return [0, 10];
|
||||
|
||||
const formattedData = data.map(num => formatNumber(num));
|
||||
const min = Math.min(...formattedData);
|
||||
const max = Math.max(...formattedData);
|
||||
|
||||
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;
|
||||
|
||||
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_YEARDATA_M001"
|
||||
};
|
||||
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 = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
if (myChart) {
|
||||
myChart.dispose();
|
||||
if (!myChart) {
|
||||
myChart = echarts.init(chartDom.value);
|
||||
}
|
||||
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
if (rawData.value.length === 0) {
|
||||
myChart.setOption({
|
||||
@@ -157,10 +121,10 @@ const initChart = () => {
|
||||
textStyle: { fontSize: 18, color: '#333' }
|
||||
},
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['收入', '支出'], top: 10, textStyle: { fontSize: 12 } },
|
||||
legend: { data: ['收入', '支出', '支出占比'], top: 10, textStyle: { fontSize: 12 } },
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
right: 40,
|
||||
bottom: 60,
|
||||
top: 40,
|
||||
containLabel: true
|
||||
@@ -188,12 +152,26 @@ const initChart = () => {
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
formatter: (value: number) => value.toFixed(2)
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
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: [],
|
||||
@@ -203,21 +181,44 @@ const initChart = () => {
|
||||
position: 'center',
|
||||
effect: 'none'
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const xAxisData = rawData.value.map(item => {
|
||||
const dateStr = item.monthDate || '';
|
||||
const monthMatch = dateStr.match(/(\d{1,2})$/);
|
||||
return monthMatch ? `${monthMatch[1]}月` : dateStr;
|
||||
}).filter(Boolean);
|
||||
|
||||
const incomeData = rawData.value.map(item => toTenThousandYuan(item.incomeAmount));
|
||||
const expenseData = rawData.value.map(item => toTenThousandYuan(item.expenseAmount));
|
||||
const xAxisData = rawData.value.map(item => formatMonth(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: {
|
||||
@@ -234,33 +235,28 @@ const initChart = () => {
|
||||
borderWidth: 1,
|
||||
formatter: (params: any[]) => {
|
||||
if (!params || params.length === 0) return '<div style="padding: 8px;">暂无数据</div>';
|
||||
|
||||
const currentDate = params[0]?.axisValue || '';
|
||||
const item = rawData.value.find(i => {
|
||||
const monthMatch = (i.monthDate || '').match(/(\d{1,2})$/);
|
||||
return monthMatch && `${monthMatch[1]}月` === currentDate;
|
||||
});
|
||||
|
||||
if (!item) return `<div style="padding: 8px;">${currentDate}:暂无明细</div>`;
|
||||
|
||||
const netProfit = calculateNetProfit(item.incomeAmount, item.expenseAmount);
|
||||
const currentMonth = params[0]?.axisValue || '';
|
||||
const item = rawData.value.find(i => formatMonth(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;">${currentDate}</div>
|
||||
<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.incomeAmount)} 元</td>
|
||||
<td style="padding: 8px; border: 1px solid #e8e8e8; text-align: center; color: #f5222d;">${formatWithThousandsSeparator(item.expenseAmount)} 元</td>
|
||||
<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>
|
||||
@@ -268,14 +264,14 @@ const initChart = () => {
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
data: ['收入', '支出', '支出占比'],
|
||||
top: 10,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 11 }
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
right: 40,
|
||||
bottom: 60,
|
||||
top: 40,
|
||||
containLabel: true
|
||||
@@ -305,7 +301,7 @@ const initChart = () => {
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
formatter: (value: number) => formatNumber(value).toFixed(2)
|
||||
formatter: (value: number) => value.toFixed(2)
|
||||
},
|
||||
min: amountMin,
|
||||
max: amountMax,
|
||||
@@ -313,7 +309,25 @@ const initChart = () => {
|
||||
splitLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
axisTick: { alignWithLabel: true },
|
||||
splitNumber: 5,
|
||||
scale: true
|
||||
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: {
|
||||
@@ -370,13 +384,39 @@ const initChart = () => {
|
||||
])
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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'
|
||||
};
|
||||
|
||||
myChart.setOption(option);
|
||||
myChart.setOption(option, true);
|
||||
};
|
||||
|
||||
const resizeChart = () => {
|
||||
@@ -390,28 +430,40 @@ const resizeChart = () => {
|
||||
}
|
||||
};
|
||||
|
||||
let resizeTimer: number;
|
||||
const debounceResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resizeChart, 100);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchList();
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
watch(rawData, () => {
|
||||
initChart();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,24 +8,24 @@
|
||||
<div class="layout-container">
|
||||
<div class="left-panel">
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">本年收入</span>
|
||||
<span class="stat-title">收入</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-income.png" size="28"/>
|
||||
<span class="stat-value">{{ thisIncome }} 元</span>
|
||||
<span class="stat-value">{{ thisIncome }} (元)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">本年支出</span>
|
||||
<span class="stat-title">支出</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-expense.png" size="28"/>
|
||||
<span class="stat-value">{{ thisExpense }} 元</span>
|
||||
<span class="stat-value">{{ thisExpense }} (元)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-title">消费占比</span>
|
||||
<span class="stat-title">占比</span>
|
||||
<div class="stat-content">
|
||||
<Icon icon="icons/erp-share.png" size="28"/>
|
||||
<span class="stat-value">{{ thisShare }} %</span>
|
||||
<span class="stat-value">{{ thisShare }} (%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,31 +41,29 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { Icon } from '@jeesite/core/components/Icon';
|
||||
import { erpAccountListAll } from '@jeesite/erp/api/erp/account';
|
||||
import { erpIncExpRatioListAll } from '@jeesite/erp/api/erp/incExpRatio';
|
||||
import { bizItemInfoListAll } from '@jeesite/biz/api/biz/itemInfo';
|
||||
import * as echarts from 'echarts';
|
||||
import type { ECharts, EChartsOption } from 'echarts';
|
||||
|
||||
// 核心:初始化时明确赋值,避免null
|
||||
const props = defineProps<{
|
||||
formParams: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const listAccount = ref<any[]>([]);
|
||||
const chartDom = ref<HTMLDivElement | null>(null);
|
||||
let myChart: ECharts | null = null;
|
||||
let resizeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const thisIncome = ref(0);
|
||||
const thisExpense = ref(0);
|
||||
const thisShare = ref(0);
|
||||
|
||||
const allLegendNames = ref<string[]>([]);
|
||||
const selectedMap = ref<Record<string, boolean>>({});
|
||||
|
||||
// 总金额计算(增加空值防护)
|
||||
const totalAmountText = computed(() => {
|
||||
if (!listAccount.value.length) return '0.00';
|
||||
|
||||
let total = 0;
|
||||
listAccount.value.forEach(item => {
|
||||
const name = item.accountName || '未知账户';
|
||||
// 未显式取消的都算选中
|
||||
if (selectedMap.value[name] !== false) {
|
||||
total += Number(item.currentBalance || 0);
|
||||
}
|
||||
@@ -73,14 +71,12 @@ const totalAmountText = computed(() => {
|
||||
return total.toFixed(2);
|
||||
});
|
||||
|
||||
// 百分比格式化(增强容错)
|
||||
const formatPercent = (value: number) => {
|
||||
const parsed = Number(value);
|
||||
if (isNaN(parsed) || !isFinite(parsed)) return '0.0%';
|
||||
return `${parsed.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// 颜色生成(简化逻辑)
|
||||
const generateRandomColors = (count: number): string[] => {
|
||||
const colorLibrary = [
|
||||
'#1890ff', '#52c41a', '#f5a623', '#fa8c16', '#722ed1', '#eb2f96',
|
||||
@@ -88,9 +84,7 @@ const generateRandomColors = (count: number): string[] => {
|
||||
'#7cb305', '#ff7a45', '#ff4d4f', '#6b778c', '#5d7092', '#91d5ff'
|
||||
];
|
||||
|
||||
if (count <= colorLibrary.length) {
|
||||
return colorLibrary.slice(0, count);
|
||||
}
|
||||
if (count <= colorLibrary.length) return colorLibrary.slice(0, count);
|
||||
|
||||
const colors = [...colorLibrary];
|
||||
while (colors.length < count) {
|
||||
@@ -100,14 +94,16 @@ const generateRandomColors = (count: number): string[] => {
|
||||
return colors;
|
||||
};
|
||||
|
||||
// 获取本年收支数据(增加错误捕获)
|
||||
const getThisData = async () => {
|
||||
const getThisData = async (params?: Record<string, any>) => {
|
||||
try {
|
||||
const year = new Date().getFullYear();
|
||||
const res = await erpIncExpRatioListAll({ cycleType: 'Y', statDate: year });
|
||||
thisIncome.value = Number(res?.[0]?.incomeAmount) || 0;
|
||||
thisExpense.value = Number(res?.[0]?.expenseAmount) || 0;
|
||||
thisShare.value = Number(res?.[0]?.expenseRatio) || 0;
|
||||
const reqParams = {
|
||||
...(params || props.formParams),
|
||||
itemCode: "ERP_YEARPMOM_M001",
|
||||
};
|
||||
const res = await bizItemInfoListAll(reqParams);
|
||||
thisIncome.value = Number(res?.[0]?.index01) || 0;
|
||||
thisExpense.value = Number(res?.[0]?.index02) || 0;
|
||||
thisShare.value = Number(res?.[0]?.index04) || 0;
|
||||
} catch (error) {
|
||||
console.error('获取收支数据失败:', error);
|
||||
thisIncome.value = 0;
|
||||
@@ -116,7 +112,6 @@ const getThisData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取账户列表(增加错误捕获)
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const res = await erpAccountListAll();
|
||||
@@ -127,93 +122,41 @@ const fetchList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化图表(核心修复:全链路null防护)
|
||||
const initChart = () => {
|
||||
// 1. 先检查DOM是否存在
|
||||
if (!chartDom.value) {
|
||||
console.warn('图表容器DOM不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 安全销毁旧实例
|
||||
if (myChart) {
|
||||
try {
|
||||
myChart.off('legendselectchanged'); // 先解绑事件
|
||||
myChart.dispose();
|
||||
} catch (e) {
|
||||
console.warn('销毁旧图表实例失败:', e);
|
||||
}
|
||||
myChart = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 重新创建实例(强制指定尺寸)
|
||||
myChart = echarts.init(chartDom.value, undefined, {
|
||||
width: chartDom.value.clientWidth || 'auto',
|
||||
height: chartDom.value.clientHeight || 'auto'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('创建图表实例失败:', e);
|
||||
myChart = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 空数据处理(避免操作null实例)
|
||||
const buildChartOption = (): EChartsOption => {
|
||||
if (!listAccount.value.length) {
|
||||
const emptyOption: EChartsOption = {
|
||||
title: {
|
||||
text: '暂无账户数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { fontSize: 14, color: '#999' }
|
||||
},
|
||||
return {
|
||||
title: { text: '暂无账户数据', left: 'center', top: 'middle', textStyle: { fontSize: 14, color: '#999' } },
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { show: false },
|
||||
series: []
|
||||
};
|
||||
myChart.setOption(emptyOption);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 处理有效数据
|
||||
const pieData = listAccount.value
|
||||
.map(item => ({
|
||||
name: item.accountName || '未知账户',
|
||||
value: Number(item.currentBalance) || 0
|
||||
}))
|
||||
.filter(item => item.value > 0 && item.name); // 过滤空名称和0值
|
||||
.filter(item => item.value > 0 && item.name);
|
||||
|
||||
// 6. 再次空数据检查
|
||||
if (!pieData.length) {
|
||||
const emptyOption: EChartsOption = {
|
||||
title: {
|
||||
text: '暂无有效账户金额数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { fontSize: 14, color: '#999' }
|
||||
},
|
||||
return {
|
||||
title: { text: '暂无有效账户金额数据', left: 'center', top: 'middle', textStyle: { fontSize: 14, color: '#999' } },
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { show: false },
|
||||
series: []
|
||||
};
|
||||
myChart.setOption(emptyOption);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 准备图表数据
|
||||
allLegendNames.value = pieData.map(p => p.name);
|
||||
const colors = generateRandomColors(pieData.length);
|
||||
const total = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
// 8. 构建选中状态映射
|
||||
|
||||
const selected: Record<string, boolean> = {};
|
||||
pieData.forEach(item => {
|
||||
// 未设置过的默认选中
|
||||
selected[item.name] = selectedMap.value[item.name] !== false;
|
||||
});
|
||||
|
||||
// 9. 图表配置(简化且安全)
|
||||
const option: EChartsOption = {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: ({ name, value, percent }: any) => `${name}: ${value} 元 (${(percent * 100).toFixed(1)}%)`,
|
||||
@@ -232,7 +175,7 @@ const initChart = () => {
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 8,
|
||||
selected: selected, // 绑定选中状态
|
||||
selected: selected,
|
||||
formatter: (name: string) => name.length > 8 ? `${name.slice(0, 8)}...` : name
|
||||
},
|
||||
series: [{
|
||||
@@ -241,11 +184,7 @@ const initChart = () => {
|
||||
radius: ['30%', '70%'],
|
||||
center: ['40%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
},
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 1 },
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 11,
|
||||
@@ -270,80 +209,61 @@ const initChart = () => {
|
||||
}))
|
||||
}]
|
||||
};
|
||||
};
|
||||
|
||||
// 10. 设置配置项(安全执行)
|
||||
try {
|
||||
myChart.setOption(option);
|
||||
|
||||
// 11. 绑定图例事件(先解绑再绑定,避免重复)
|
||||
const initChart = () => {
|
||||
if (!chartDom.value) return;
|
||||
|
||||
if (myChart) {
|
||||
myChart.off('legendselectchanged');
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
|
||||
try {
|
||||
myChart = echarts.init(chartDom.value);
|
||||
|
||||
myChart.on('legendselectchanged', (params: any) => {
|
||||
if (params && params.name !== undefined) {
|
||||
selectedMap.value[params.name] = params.selected[params.name];
|
||||
// 无需重新初始化,ECharts会自动更新视图
|
||||
}
|
||||
});
|
||||
|
||||
const option = buildChartOption();
|
||||
myChart.setOption(option);
|
||||
} catch (e) {
|
||||
console.error('设置图表配置失败:', e);
|
||||
console.error('初始化图表失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 防抖调整图表尺寸(增加null防护)
|
||||
const resizeChart = () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (myChart && chartDom.value) {
|
||||
try {
|
||||
myChart.resize({
|
||||
width: chartDom.value.clientWidth || 'auto',
|
||||
height: chartDom.value.clientHeight || 'auto'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('调整图表尺寸失败:', e);
|
||||
}
|
||||
}
|
||||
myChart?.resize();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 监听数据变化(使用nextTick确保DOM就绪)
|
||||
watch(listAccount, () => {
|
||||
nextTick(() => initChart());
|
||||
watch(() => props.formParams, async () => {
|
||||
await Promise.all([fetchList(), getThisData()]);
|
||||
}, { deep: true });
|
||||
|
||||
// 生命周期(增加安全防护)
|
||||
onMounted(async () => {
|
||||
// 先加载数据
|
||||
await Promise.all([fetchList(), getThisData()]);
|
||||
|
||||
// DOM渲染完成后初始化图表
|
||||
nextTick(() => {
|
||||
initChart();
|
||||
// 绑定窗口大小变化事件
|
||||
window.addEventListener('resize', resizeChart);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理定时器
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
|
||||
// 解绑事件
|
||||
window.removeEventListener('resize', resizeChart);
|
||||
|
||||
// 安全销毁图表实例
|
||||
if (myChart) {
|
||||
try {
|
||||
myChart.off('legendselectchanged');
|
||||
myChart.dispose();
|
||||
} catch (e) {
|
||||
console.warn('销毁图表实例失败:', e);
|
||||
}
|
||||
myChart.off('legendselectchanged');
|
||||
myChart.dispose();
|
||||
myChart = null;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
selectedMap.value = {};
|
||||
allLegendNames.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -372,6 +292,7 @@ onUnmounted(() => {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: hidden !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
@@ -393,7 +314,8 @@ onUnmounted(() => {
|
||||
padding: 16px;
|
||||
box-sizing: border-box !important;
|
||||
overflow: hidden !important;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
@@ -406,6 +328,7 @@ onUnmounted(() => {
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
box-sizing: border-box;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@@ -448,6 +371,7 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden !important;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
@@ -473,6 +397,8 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
overflow: hidden !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
|
||||
@@ -1,46 +1,192 @@
|
||||
<template>
|
||||
<div class="erp-green-page-container">
|
||||
<div class="header-section">
|
||||
<div class="header-left">
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-shouru.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index01 || 0 }}</div>
|
||||
<div class="card-desc">总收入(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-zhichu.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index02 || 0 }}</div>
|
||||
<div class="card-desc">总支出(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-fencheng.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index03 || 0 }}</div>
|
||||
<div class="card-desc">储蓄率(%)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-zongshouru.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index04 || 0 }}</div>
|
||||
<div class="card-desc">净利润(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-jinglirun.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index05 || 0 }}</div>
|
||||
<div class="card-desc">月均收入(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-item">
|
||||
<div class="card-icon">
|
||||
<Icon icon="icons/erp-jiaoyizhichu.svg" size="48" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ indexData?.[0]?.index06 || 0 }}</div>
|
||||
<div class="card-desc">月均支出(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-right-top">
|
||||
<h2>财务数据可视化大屏</h2>
|
||||
</div>
|
||||
<div class="header-right-bottom">
|
||||
<BasicForm
|
||||
:labelWidth="100"
|
||||
:schemas="schemaForm.schemas"
|
||||
class="search-form"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-section">
|
||||
<div class="top-left">
|
||||
<ChartPie />
|
||||
<ChartPie :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="top-middle">
|
||||
<ChartLine />
|
||||
<ChartYear :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<ChartAccount :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="top-right">上右区域</div>
|
||||
</div>
|
||||
<div class="middle-section">
|
||||
<div class="middle-left">
|
||||
<ChartAccount />
|
||||
</div>
|
||||
<div class="middle-right">
|
||||
<ChartCategory />
|
||||
</div>
|
||||
<div class="middle-right-1">
|
||||
<ChartCategoryExp :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="middle-right-2">
|
||||
<ChartCategoryInc :formParams="FormValues" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="middle-left">
|
||||
<ChartLine :formParams="FormValues" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-section">
|
||||
<div class="bottom-right">
|
||||
<div class="bottom-right-1">
|
||||
<ChartRank :formParams="FormValues" />
|
||||
</div>
|
||||
<div class="bottom-right-2">
|
||||
<ChartQuarter :formParams="FormValues" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-left">
|
||||
|
||||
</div>
|
||||
<div class="bottom-right">下右区域</div>
|
||||
<ChartMom :formParams="FormValues" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="ErpGreenPage">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@jeesite/core/components/Icon';
|
||||
import { BasicForm, FormProps } from '@jeesite/core/components/Form';
|
||||
import { BizItemInfo, bizItemInfoListAll } from '@jeesite/biz/api/biz/itemInfo';
|
||||
|
||||
import ChartPie from './components/ChartPie.vue';
|
||||
import ChartMom from './components/ChartMom.vue';
|
||||
import ChartLine from './components/ChartLine.vue';
|
||||
|
||||
import ChartYear from './components/ChartYear.vue';
|
||||
import ChartRank from './components/ChartRank.vue';
|
||||
import ChartQuarter from './components/ChartQuarter.vue';
|
||||
import ChartAccount from './components/ChartAccount.vue'
|
||||
import ChartCategory from './components/ChartCategory.vue'
|
||||
import ChartCategoryExp from './components/ChartCategoryExp.vue'
|
||||
import ChartCategoryInc from './components/ChartCategoryInc.vue'
|
||||
|
||||
const currentSystemYear = new Date().getFullYear();
|
||||
|
||||
const getCurrentMonth = () => {
|
||||
const month = new Date().getMonth() + 1;
|
||||
return month.toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
const FormValues = ref<Record<string, any>>({
|
||||
reqParam: String(currentSystemYear)
|
||||
});
|
||||
|
||||
const indexData = ref<BizItemInfo[]>();
|
||||
|
||||
const schemaForm: FormProps = {
|
||||
labelWidth: 90,
|
||||
schemas: [
|
||||
{
|
||||
label: '年份',
|
||||
field: 'reqParam',
|
||||
defaultValue: new Date().getFullYear().toString(),
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: Array.from({length: 5}, (_, i) => {
|
||||
const year = new Date().getFullYear() - i;
|
||||
return { label: `${year}`, value: year.toString() };
|
||||
}),
|
||||
allowClear: false,
|
||||
onChange: (value: string) => {
|
||||
FormValues.value.reqParam = value || '';
|
||||
}
|
||||
},
|
||||
colProps: { md: 24, lg: 24 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fetchDataList = async () => {
|
||||
try {
|
||||
const reqParams = {
|
||||
xAxis: getCurrentMonth(),
|
||||
reqParam: String(currentSystemYear),
|
||||
itemCode: "ERP_YEARPSAV_M001",
|
||||
};
|
||||
const result = await bizItemInfoListAll(reqParams);
|
||||
indexData.value = result || [];
|
||||
} catch (error) {
|
||||
console.error('获取数据列表失败:', error);
|
||||
indexData.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDataList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.erp-green-page-container {
|
||||
height: calc(100vh - 100px);
|
||||
margin: 0;
|
||||
@@ -48,37 +194,190 @@ import ChartCategory from './components/ChartCategory.vue'
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
height: 10%;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 1px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
margin: 0 2px 4px 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 85%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
background-color: #f0f8fb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #b3d9f2;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
flex: 1;
|
||||
height: 80%;
|
||||
margin: 0 4px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-item:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 15%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #b3d9f2;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
background-color: #f8fcff;
|
||||
}
|
||||
|
||||
.header-right-top {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background-color: #e6f7ff;
|
||||
overflow: hidden;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #91d5ff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.header-right-top h2 {
|
||||
font-size: clamp(14px, 1.8vw, 18px);
|
||||
font-weight: 700;
|
||||
color: #0958d9;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-right-bottom {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background-color: #f0f8fb;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #b3d9f2;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:deep(.header-right-bottom .search-form) {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
:deep(.header-right-bottom .ant-form) {
|
||||
width: 100% !important;
|
||||
}
|
||||
:deep(.header-right-bottom .ant-form-item) {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
:deep(.header-right-bottom .ant-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
height: 30%; /* 顶部占30% */
|
||||
height: 30%;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.top-left, .top-middle, .top-right {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
/* 子元素垂直居中 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.middle-section {
|
||||
height: 30%;
|
||||
height: 30%;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.middle-left, .middle-right {
|
||||
.middle-left {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
@@ -89,15 +388,34 @@ import ChartCategory from './components/ChartCategory.vue'
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.middle-right {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.middle-right-1, .middle-right-2 {
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f8fb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bottom-section {
|
||||
height: 40%;
|
||||
height: 30%;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bottom-left, .bottom-right {
|
||||
.bottom-left {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
@@ -108,6 +426,26 @@ import ChartCategory from './components/ChartCategory.vue'
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom-right {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bottom-right-1, .bottom-right-2 {
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f8fb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.top-middle) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user