新增前端vue

This commit is contained in:
2025-12-10 16:31:38 +08:00
parent d8c8f60eb5
commit af34bfa2f0
5 changed files with 561 additions and 23 deletions

View File

@@ -0,0 +1,514 @@
<template>
<Card title="账户汇总图" style="width: 100%; height: 400px; margin: 4px 0;">
<template #extra>
<BasicForm
:labelWidth="100"
:schemas="schemas"
:initialValues="defaultFormValues"
@submit="handleFormSubmit"
style="width: 200px;"
/>
</template>
<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 defaultFormValues = ref({
cycleType: 'M'
});
// 表单配置
const schemas: FormSchema[] = [
{
label: '周期',
field: 'cycleType',
defaultValue: defaultFormValues.value.cycleType,
component: 'Select',
componentProps: {
dictType: 'report_cycle',
allowClear: true,
onChange: (value: string) => {
defaultFormValues.value.cycleType = value;
fetchList({ cycleType: value });
}
},
colProps: { md: 24, lg: 24 },
},
];
// 图表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: { cycleType?: string }) => {
if (loading.value) return;
try {
loading.value = true;
const cycleType = params.cycleType || defaultFormValues.value.cycleType;
const result = await erpExpIncListAll({ cycleType });
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: 25, // 加宽柱子,适配总值显示
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: '' };
}
// 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: [10, 15],
formatter: function(params: any) {
if (!params || params.length === 0) return '';
// 获取当前银行名称
const bankName = params[0]?.axisValue || '';
const bankDetails = bankDetailMap[bankName] || {};
// 生成各周期明细行
let cycleRows = '';
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) {
cycleRows += `
<div style="display: flex; justify-content: space-between; margin: 4px 0;">
<span style="display: flex; align-items: center;">
<span style="display: inline-block; width: 8px; height: 8px; background: ${incomeYuan > 0 ? '#52c41a' : '#f5222d'}; border-radius: 2px; margin-right: 6px;"></span>
${date}
</span>
<span style="display: flex; gap: 15px;">
<span style="color: #52c41a;">收入:${formatNumber(incomeYuan)} </span>
<span style="color: #f5222d;">支出:${formatNumber(expenseYuan)} </span>
</span>
</div>
`;
}
});
// 计算最新周期汇总
const latestDetail = bankDetails[latestDate] || { income: 0, expense: 0 };
const latestIncome = convertToYuan(latestDetail.income);
const latestExpense = convertToYuan(latestDetail.expense);
const netIncome = latestIncome - latestExpense;
return `
<div style="font-weight: 600; margin-bottom: 8px;">${bankName}</div>
${cycleRows}
<div style="border-top: 1px solid #eee; margin: 8px 0; padding-top: 8px; display: flex; justify-content: space-between; font-weight: 600;">
<span>本期总收入:</span>
<span style="display: flex; gap: 15px;">
<span style="color: #52c41a;">收入:${formatNumber(latestIncome)} 元</span>
<span style="color: #f5222d;">支出:${formatNumber(latestExpense)} 元</span>
</span>
</div>
<div style="text-align: left; color: #333; font-weight: 600;">
本期净收入:${formatNumber(netIncome)}
</div>
`;
}
},
legend: {
data: ['收入', '支出'],
top: 10,
left: 'center',
orient: 'horizontal',
textStyle: { fontSize: 12, color: '#333' },
itemWidth: 12,
itemHeight: 12,
itemGap: 20
},
grid: {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true,
},
xAxis: {
type: 'category',
data: accountNames,
axisLabel: {
fontSize: 12,
rotate: 15, // 银行名称旋转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 handleFormSubmit = (values: { cycleType?: string }) => {
if (values.cycleType) {
fetchList({ cycleType: values.cycleType });
}
};
/**
* 窗口缩放自适应
*/
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({});
window.addEventListener('resize', resizeChart);
});
onUnmounted(() => {
window.removeEventListener('resize', resizeChart);
if (myChart) {
myChart.dispose();
myChart = null;
}
clearTimeout((window as any).chartResizeTimer);
});
</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: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
border: none;
background: #fff;
z-index: 9999 !important;
max-width: 400px; /* 限制Tooltip宽度避免过宽 */
}
/* 优化标签显示效果 */
: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;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<Card title="银行收支汇总图" style="width: 100%; height: 400px; margin: 4px 0;">
<Card title="周期汇总图" style="width: 100%; height: 400px; margin: 4px 0;">
<template #extra>
<BasicForm
:labelWidth="100"

View File

@@ -1,5 +1,5 @@
<template>
<Card title="交易收支柱线图" style="width: 100%; height: 400px; margin: 4px 0;">
<Card title="交易柱线图" style="width: 100%; height: 400px; margin: 4px 0;">
<template #extra>
<BasicForm
:labelWidth="100"
@@ -12,7 +12,6 @@
<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';
@@ -74,7 +73,12 @@ const formatNumber = (num: number | undefined, decimal = 2): number => {
return Number(num.toFixed(decimal));
};
// 柱状图标签配置保留2位小数
// 转换为万元保留2位小数
const toTenThousandYuan = (num: number | undefined): number => {
return formatNumber((num || 0) / 10000);
};
// 柱状图标签配置(显示万元)
const barLabelConfig = {
show: true,
position: 'top',
@@ -84,7 +88,7 @@ const barLabelConfig = {
color: '#333',
fontWeight: '500'
},
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)}`
formatter: (params: any) => `${formatNumber(params.value).toFixed(2)} `
};
// 折线图标签配置保留2位小数
@@ -165,9 +169,9 @@ const initChart = () => {
? listSummary.value.map(item => item.cdate || '')
: [];
// 提取金额数据用于计算Y轴范围保留2位小数
const thisValueData = listSummary.value.map(item => formatNumber(item.thisValue));
const prevValueData = listSummary.value.map(item => formatNumber(item.prevValue));
// 提取金额数据(转换为万元)用于计算Y轴范围
const thisValueData = listSummary.value.map(item => toTenThousandYuan(item.thisValue));
const prevValueData = listSummary.value.map(item => toTenThousandYuan(item.prevValue));
const amountData = [...thisValueData, ...prevValueData];
const [amountMin, amountMax] = calculateYAxisExtent(amountData);
@@ -175,6 +179,10 @@ const initChart = () => {
const rateData = listSummary.value.map(item => formatNumber(item.momRate));
const [rateMin, rateMax] = calculateYAxisExtent(rateData, true);
// 原始金额数据用于tooltip显示元
const rawThisValueData = listSummary.value.map(item => formatNumber(item.thisValue));
const rawPrevValueData = listSummary.value.map(item => formatNumber(item.prevValue));
const option = {
title: {
left: 'center',
@@ -186,10 +194,20 @@ const initChart = () => {
textStyle: { fontSize: 12 },
formatter: (params: any[]) => {
let res = params[0].axisValue;
params.forEach((param) => {
const value = formatNumber(param.value).toFixed(2);
const unit = param.seriesName === '环比' ? ' %' : ' 元';
res += `<br/>${param.marker}${param.seriesName}${value}${unit}`;
params.forEach((param, index) => {
// 区分金额和环比:金额显示原始元,环比显示百分比
let value = param.value;
let unit = ' 元';
if (param.seriesName === '环比') {
value = formatNumber(param.value);
unit = ' %';
} else {
// 还原为原始元数值图表展示的是万元需要乘10000
value = formatNumber(param.value * 10000);
}
res += `<br/>${param.marker}${param.seriesName}${value.toFixed(2)}${unit}`;
});
return res;
}
@@ -218,7 +236,7 @@ const initChart = () => {
yAxis: [
{
type: 'value',
name: '交易金额(元)',
name: '交易金额(元)', // Y轴名称改为万元
axisLabel: {
fontSize: 12,
formatter: (value: number) => formatNumber(value).toFixed(2)
@@ -264,7 +282,7 @@ const initChart = () => {
{
name: '本期金额',
type: 'bar',
data: thisValueData,
data: thisValueData, // 展示万元数据
itemStyle: {
color: '#1890ff',
borderRadius: [8, 8, 0, 0]
@@ -272,12 +290,14 @@ const initChart = () => {
barWidth: 25,
barBorderRadius: [8, 8, 0, 0],
label: barLabelConfig,
yAxisIndex: 0
yAxisIndex: 0,
// 存储原始元数据用于tooltip
rawData: rawThisValueData
},
{
name: '上期金额',
type: 'bar',
data: prevValueData,
data: prevValueData, // 展示万元数据
itemStyle: {
color: '#52c41a',
borderRadius: [8, 8, 0, 0]
@@ -285,7 +305,9 @@ const initChart = () => {
barWidth: 25,
barBorderRadius: [8, 8, 0, 0],
label: barLabelConfig,
yAxisIndex: 0
yAxisIndex: 0,
// 存储原始元数据用于tooltip
rawData: rawPrevValueData
},
{
name: '环比',

View File

@@ -1,5 +1,5 @@
<template>
<Card title="银行账户余额占比" style="width: 100%; height: 400px; margin: 4px 0;">
<Card title="账户余额占比" style="width: 100%; height: 400px; margin: 4px 0;">
<div ref="chartDom" style="width: 100%; height: 300px;"></div>
</Card>
</template>

View File

@@ -2,21 +2,23 @@
<div class="dashboard-container">
<div class="two-column-layout">
<ChartPie />
<ChartBar />
<ChartLine />
</div>
<div class="two-column-layout">
<ChartBarCycle />
<ChartBarAccount />
</div>
<ChartLine class="chart-line-wrapper" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { Card } from 'ant-design-vue';
import ChartBar from './components/ChartBar.vue';
import ChartPie from './components/ChartPie.vue';
import ChartLine from './components/ChartLine.vue';
import ChartBarCycle from './components/ChartBarCycle.vue';
import ChartBarAccount from './components/ChartBarAccount.vue';
</script>
<style scoped>
.dashboard-container {
padding: 0 8px;