财务门户设计

This commit is contained in:
2026-02-18 18:32:37 +08:00
parent 20c40a8f43
commit 82b84fc6b5
20 changed files with 816 additions and 5254 deletions

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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%;