feat(statistics): 添加统计分析模块:天医币、实物、培训班。

- 新增天医币、实物和培训班报表页面
- 添加统计相关API接口
- 配置统计分析路由
- 优化SheetFooter和DialogFooter组件样式
This commit is contained in:
2026-01-14 09:24:24 +08:00
parent 77c1b37f2e
commit dff302aae8
11 changed files with 511 additions and 60 deletions

View File

@@ -0,0 +1,33 @@
import { requestClient } from '#/api/request';
export const statisticsApi = {
/**
* 获取天医币报表列表
*/
getReportTianyibi: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/pointStatistics', data);
},
/**
* 获取实物报表列表
*/
getPhysicalStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/physicalStatistics', data);
},
/**
* 获取培训班报表列表
*/
getTrainingClassStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/trainingClassStatistics', data);
},
/**
* 下载天医币报表
*/
downloadReportTianyibi: (data: { date: string }) => {
return requestClient.post('/common/import/getImportFile', data, {
responseType: 'blob',
});
},
};

View File

@@ -0,0 +1,45 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'mdi:chart-areaspline',
keepAlive: true,
order: 1000,
title: '统计分析',
},
name: 'Statistics',
path: '/statistics',
children: [
{
meta: {
title: '天医币报表',
keepAlive: true,
},
name: 'TianyibiReport',
path: '/statistics/tianyibi-report',
component: () => import('#/views/statistics/tianyibi/report.vue'),
},
{
meta: {
title: '实物报表',
keepAlive: true,
},
name: 'PhysicalReport',
path: '/statistics/physical-report',
component: () => import('#/views/statistics/physical/report.vue'),
},
{
meta: {
title: '培训班报表',
keepAlive: true,
},
name: 'TrainingClassReport',
path: '/statistics/training-class-report',
component: () => import('#/views/statistics/trainingClass/report.vue'),
},
],
},
];
export default routes;

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import { Page, Spinner } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { Button, Card, DatePicker, Empty } from 'ant-design-vue';
import dayjs from 'dayjs';
import { statisticsApi } from '#/api/statistics';
const year = ref<Dayjs>(dayjs());
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface ReportItem {
type: string;
fee: number;
count: number;
icon: {
color: string;
icon: string;
};
}
// 报表数据
const list = ref<ReportItem[][]>([]);
const loading = ref<boolean>(false);
const getList = async () => {
loading.value = true;
list.value = [];
try {
// 计算查询年份中包含哪些月份, 若为今年则只查询到当前月份,往年则查询所有月份
const monthList =
year.value.year() === dayjs().year()
? Array.from({ length: dayjs().month() + 1 }, (_, i) => (i + 1).toString().padStart(2, '0'))
: Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
for (const month of monthList) {
const data = await statisticsApi.getPhysicalStatistics({
year: year.value.year(),
month,
});
// 确保四种支付方式都存在默认值为0
const paymentTypes = [
{ type: '微信', icon: { color: 'text-green-500', icon: 'ant-design:wechat-filled' } },
{ type: '支付宝', icon: { color: 'text-blue-500', icon: 'ant-design:alipay-outlined' } },
{ type: '银行', icon: { color: 'text-yellow-500', icon: 'ant-design:credit-card-filled' } },
{
type: '天医币',
icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' },
},
];
const report: ReportItem[] = paymentTypes.map((type) => {
const item = data.map.find((item: any) => item.type === type.type);
return {
type: type.type,
fee: item?.fee || 0,
count: item?.count || 0,
icon: { color: type.icon.color, icon: type.icon.icon },
};
});
list.value.push(report);
}
} finally {
loading.value = false;
}
};
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full flex-col rounded-md bg-white">
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate"
@change="getList"
/>
<Button type="primary" class="ml-2" @click="getList">查询</Button>
</div>
<div class="h-2 bg-gray-100"></div>
<Spinner class="content flex-1 px-3 py-4" :spinning="loading">
<div
v-if="list.length === 0 && !loading"
class="col-span-3 flex items-center justify-center"
>
<Empty />
</div>
<div v-else class="grid max-h-full grid-cols-3 gap-3 overflow-auto px-1">
<Card v-for="(item, index) in list" :key="index" :title="`${index + 1} 月`" size="small">
<div class="-m-2 text-[16px]">
<div class="flex flex-row py-2">
<div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center">
<div class="flex items-center justify-center font-bold">
<VbenIcon
class="size-5"
:class="payment.icon.color"
:icon="payment.icon.icon"
fallback
/>
{{ payment.type }}
</div>
<!-- <div class="text-gray-500">{{ payment.count }}</div> -->
<div class="text-black">{{ payment.fee }}</div>
</div>
</div>
</div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template>
<style scoped lang="scss">
:deep(.ant-card-head-title) {
font-size: 16px !important;
}
</style>

View File

@@ -0,0 +1,149 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import { Page, Spinner } from '@vben/common-ui';
// import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, Card, DatePicker, Empty } from 'ant-design-vue';
import dayjs from 'dayjs';
import { statisticsApi } from '#/api/statistics';
const year = ref<Dayjs>(dayjs());
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface Report {
incomes: Record<string, number>;
consumes: Record<string, number>;
surplus: number;
}
interface ReportItem {
type: string;
fee: number;
}
// 报表数据
const list = ref<Report[]>([]);
const loading = ref<boolean>(false);
const getList = async () => {
loading.value = true;
list.value = [];
try {
// 计算查询年份中包含哪些月份, 若为今年则只查询到当前月份,往年则查询所有月份
const monthList =
year.value.year() === dayjs().year()
? Array.from({ length: dayjs().month() + 1 }, (_, i) => (i + 1).toString().padStart(2, '0'))
: Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
for (const month of monthList) {
const data = await statisticsApi.getReportTianyibi({
year: year.value.year(),
month,
});
list.value.push({
incomes: {
微信: 0,
支付宝: 0,
银行: 0,
...Object.fromEntries(data.map.incomes.map((item: ReportItem) => [item.type, item.fee])),
},
consumes: {
实物: 0,
培训班: 0,
vip: 0,
课程: 0,
...Object.fromEntries(data.map.consumes.map((item: ReportItem) => [item.type, item.fee])),
},
surplus: data.map.surplus,
});
}
} finally {
loading.value = false;
}
};
// 下载报表
// const downloadReport = async (index: number) => {
// const month = index > 9 ? `${index + 1}` : `0${index + 1}`;
// const date = `${year.value.year()}-${month}`;
// const filename = `天医币报表_${year.value.year()}年${month}月_文件.xlsx`;
// const res = await statisticsApi.downloadReportTianyibi({
// date,
// });
// downloadFileFromBlobPart({
// source: res.data,
// fileName: filename,
// });
// };
// const downloadAllReport = () => {
// list.value.forEach((_, index) => {
// downloadReport(index);
// });
// };
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full flex-col rounded-md bg-white">
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate"
@change="getList"
/>
<Button type="primary" class="ml-2" @click="getList">查询</Button>
<!-- <Button type="link" class="ml-2" @click="downloadAllReport">
下载 {{ year.year() }} 年全部天医币报表
</Button> -->
</div>
<div class="h-2 bg-gray-100"></div>
<Spinner class="content flex-1 px-3 py-4" :spinning="loading">
<div
v-if="list.length === 0 && !loading"
class="col-span-3 flex items-center justify-center"
>
<Empty />
</div>
<div v-else class="grid max-h-full grid-cols-3 gap-3 overflow-auto px-1">
<Card v-for="(item, index) in list" :key="index" :title="`${index + 1} 月`" size="small">
<!-- <template #extra>
<Button type="link" @click="downloadReport(index)">下载报表</Button>
</template> -->
<div class="-m-2 text-[16px]">
<div class="flex">
<div class="flex-1 bg-[#F6FFF5] px-2 pb-1">
<div class="p-1 text-center font-bold">进项</div>
<div v-for="(fee, type) in item.incomes" :key="type" class="p-1">
<span class="text-gray-500">{{ type }}</span>
<span class="text-black">{{ fee }}</span>
</div>
</div>
<div class="flex-1 bg-[#FFFBF0] px-2 pb-1">
<div class="p-1 text-center font-bold">出项</div>
<div v-for="(fee, type) in item.consumes" :key="type" class="p-1">
<span class="text-gray-500">{{ type }}</span>
<span class="text-black">{{ fee }}</span>
</div>
</div>
</div>
<div class="px-2">APP用户剩余天医币 : {{ item.surplus }}</div>
</div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template>
<style scoped lang="scss">
:deep(.ant-card-head-title) {
font-size: 16px !important;
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import { Page, Spinner } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { Button, Card, DatePicker, Empty } from 'ant-design-vue';
import dayjs from 'dayjs';
import { statisticsApi } from '#/api/statistics';
const year = ref<Dayjs>(dayjs());
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface ReportItem {
type: string;
fee: number;
count: number;
icon: {
color: string;
icon: string;
};
}
// 报表数据
const list = ref<ReportItem[][]>([]);
const loading = ref<boolean>(false);
const getList = async () => {
loading.value = true;
list.value = [];
try {
// 计算查询年份中包含哪些月份, 若为今年则只查询到当前月份,往年则查询所有月份
const monthList =
year.value.year() === dayjs().year()
? Array.from({ length: dayjs().month() + 1 }, (_, i) => (i + 1).toString().padStart(2, '0'))
: Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
for (const month of monthList) {
const data = await statisticsApi.getTrainingClassStatistics({
year: year.value.year(),
month,
});
// 确保四种支付方式都存在默认值为0
const paymentTypes = [
{ type: '微信', icon: { color: 'text-green-500', icon: 'ant-design:wechat-filled' } },
{ type: '支付宝', icon: { color: 'text-blue-500', icon: 'ant-design:alipay-outlined' } },
{ type: '银行', icon: { color: 'text-yellow-500', icon: 'ant-design:credit-card-filled' } },
{
type: '天医币',
icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' },
},
];
const report: ReportItem[] = paymentTypes.map((type) => {
const item = data.map.find((item: any) => item.type === type.type);
return {
type: type.type,
fee: item?.fee || 0,
count: item?.count || 0,
icon: { color: type.icon.color, icon: type.icon.icon },
};
});
list.value.push(report);
}
} finally {
loading.value = false;
}
};
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full flex-col rounded-md bg-white">
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate"
@change="getList"
/>
<Button type="primary" class="ml-2" @click="getList">查询</Button>
</div>
<div class="h-2 bg-gray-100"></div>
<Spinner class="content flex-1 px-3 py-4" :spinning="loading">
<div
v-if="list.length === 0 && !loading"
class="col-span-3 flex items-center justify-center"
>
<Empty />
</div>
<div v-else class="grid max-h-full grid-cols-3 gap-3 overflow-auto px-1">
<Card v-for="(item, index) in list" :key="index" :title="`${index + 1} 月`" size="small">
<div class="-m-2 text-[16px]">
<div class="flex flex-row py-2">
<div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center">
<div class="flex items-center justify-center font-bold">
<VbenIcon
class="size-5"
:class="payment.icon.color"
:icon="payment.icon.icon"
fallback
/>
{{ payment.type }}
</div>
<!-- <div class="text-gray-500">{{ payment.count }}</div> -->
<div class="text-black">{{ payment.fee }}</div>
</div>
</div>
</div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template>
<style scoped lang="scss">
:deep(.ant-card-head-title) {
font-size: 16px !important;
}
</style>

View File

@@ -1,14 +1,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
<script lang="ts" setup></script>
import { TabPane, Tabs } from 'ant-design-vue';
const activeKey = ref(1);
</script>
<template>
<div>
<Tabs v-model:active-key="activeKey" :style="{ height: '200px' }">
<TabPane v-for="i in 30" :key="i" :tab="`Tab-${i}`">Content of tab {{ i }}</TabPane>
</Tabs>
</div>
<div></div>
</template>
<style lang="scss" scoped></style>