Compare commits

..

8 Commits

Author SHA1 Message Date
1468891bf5 fix(api): 为默认请求客户端设置无超时限制
Some checks failed
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
避免因网络延迟导致请求意外中断,将默认请求客户端的超时时间设置为0
2026-02-03 10:09:36 +08:00
5c2c5b92dd feat(报表): 扩展天医币报表收入项显示点数和金额
修改收入项数据结构,支持同时显示金额和点数
调整前端布局和显示格式,当点数与金额不同时显示点数信息
2026-01-27 14:34:35 +08:00
4b9f79192f fix(财务): 修复天易币订单查询表单和接口参数问题
为手机号字段添加默认空值,避免未填写时传undefined
在查询接口中添加source和type参数的默认空值处理
2026-01-27 14:34:22 +08:00
5c39bd4113 feat(报表): 重构报表模块并添加下载功能
Some checks failed
Close stale issues / stale (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
重构各报表页面,提取公共逻辑到useMonthReport和MonthReportView组件
添加报表下载功能,支持单月下载和全年下载
统一各报表的API调用方式和数据处理逻辑
优化代码结构,减少重复代码
2026-01-22 18:29:53 +08:00
be5078ae0d chore(auth): 解决ts 类型检查报错 2026-01-22 14:38:19 +08:00
d712f9a5bf fix(对账): 修复删除已对账数据时索引变化导致的问题
Some checks failed
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-01-16 19:02:08 +08:00
b7eb73c401 feat(button): 添加SubmitButton组件并支持ant-design主题 2026-01-16 18:46:18 +08:00
5b40af560e refactor(statistics): 替换 Spinner 为 Loading 组件 2026-01-16 17:57:01 +08:00
25 changed files with 777 additions and 579 deletions

View File

@@ -14,6 +14,10 @@ export namespace AuthApi {
* 最后登录时间 * 最后登录时间
*/ */
lastTime: string; lastTime: string;
/**
* 角色列表
*/
role: string;
} }
/** 登录接口返回值 */ /** 登录接口返回值 */

View File

@@ -0,0 +1,111 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
console.warn('Access token or refresh token is invalid or expired. ');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (preferences.app.loginExpiredMode === 'modal' && accessStore.isAccessChecked) {
accessStore.setLoginExpired(true);
} else {
await authStore.logout();
}
}
/**
* 刷新token逻辑
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const resp = await refreshTokenApi();
const newToken = resp.data;
accessStore.setAccessToken(newToken);
return newToken;
}
function formatToken(token: null | string) {
return token ? `${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
config.headers.Token = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 0,
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
);
return client;
}
export const defaultRequestClient = createRequestClient(apiURL, {
responseReturn: 'data',
timeout: 0,
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });

View File

@@ -1,3 +1,4 @@
import { defaultRequestClient } from '#/api/defaultRequest';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export const statisticsApi = { export const statisticsApi = {
@@ -58,10 +59,62 @@ export const statisticsApi = {
/** /**
* 下载天医币报表 * 下载天医币报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 天医币报表数据
*/ */
downloadReportTianyibi: (data: { date: string }) => { downloadReportTianyibi: (data: { month?: string; year: number }) => {
return requestClient.post('/common/import/getImportFile', data, { return defaultRequestClient.download<Blob>('common/statistics/pointInfoExport', {
responseType: 'blob', data,
});
},
/**
* 下载实物报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 实物报表数据
*/
downloadReportPhysical: (data: { month?: string; year: number }) => {
return defaultRequestClient.download<Blob>('common/statistics/physicalInfoExport', {
data,
});
},
/**
* 下载培训班报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 培训班报表数据
*/
downloadReportTrainingClass: (data: { month?: string; year: number }) => {
return defaultRequestClient.download<Blob>('common/statistics/trainingClassInfoExport', {
data,
});
},
/**
* 下载VIP报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns VIP报表数据
*/
downloadReportVip: (data: { month?: string; year: number }) => {
return defaultRequestClient.download<Blob>('common/statistics/vipInfoExport', {
data,
});
},
/**
* 下载课程报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 课程报表数据
*/
downloadReportCourse: (data: { month?: string; year: number }) => {
return defaultRequestClient.download<Blob>('common/statistics/courseInfoExport', {
data,
}); });
}, },
}; };

View File

@@ -93,7 +93,7 @@ function setupAccessGuard(router: Router) {
// 生成路由表 // 生成路由表
// 当前登录用户拥有的角色标识列表 // 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo()); const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? []; const userRoles = userInfo?.roles ?? [];
// 生成菜单和路由 // 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({ const { accessibleMenus, accessibleRoutes } = await generateAccess({
@@ -109,7 +109,7 @@ function setupAccessGuard(router: Router) {
accessStore.setIsAccessChecked(true); accessStore.setIsAccessChecked(true);
const redirectPath = (from.query.redirect ?? const redirectPath = (from.query.redirect ??
(to.path === preferences.app.defaultHomePath (to.path === preferences.app.defaultHomePath
? userInfo.homePath || preferences.app.defaultHomePath ? userInfo?.homePath || preferences.app.defaultHomePath
: to.fullPath)) as string; : to.fullPath)) as string;
return { return {

View File

@@ -43,6 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
// ]); // ]);
userInfo = userEntity; userInfo = userEntity;
userInfo.roles = userEntity.role ? [userEntity.role] : [];
userStore.setUserInfo(userInfo); userStore.setUserInfo(userInfo);
// accessStore.setAccessCodes(accessCodes); // accessStore.setAccessCodes(accessCodes);

View File

@@ -6,7 +6,7 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { Page } from '@vben/common-ui'; import { Page, SubmitButton } from '@vben/common-ui';
import { Button, message, notification, Tag } from 'ant-design-vue'; import { Button, message, notification, Tag } from 'ant-design-vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -128,6 +128,7 @@ const gridOptions: VxeTableGridOptions<PaymentRowType> = {
limit: page.pageSize, limit: page.pageSize,
year: date[0], year: date[0],
month: date[1], month: date[1],
type: formValues.type ?? '',
...formValues, ...formValues,
}); });
}, },
@@ -152,9 +153,7 @@ async function onStartSysCheck() {
month: date[1], month: date[1],
}; };
// 调用自动核对接口 // 调用自动核对接口
const hide = message.loading('系统自动核对中...', 0);
await reconciliateBillsApi.autoCheck(params); await reconciliateBillsApi.autoCheck(params);
hide();
gridApi?.query(); gridApi?.query();
notification.success({ notification.success({
message: '完成', message: '完成',
@@ -208,7 +207,7 @@ async function onManualCheck(data?: PaymentRowType) {
<Grid> <Grid>
<template #toolbar-actions> <template #toolbar-actions>
<div class="flex gap-2"> <div class="flex gap-2">
<Button type="primary" @click="onStartSysCheck()">启动系统自动对账</Button> <SubmitButton theme="ant-design" :submit="onStartSysCheck" text="启动系统自动对账" />
<Button type="primary" @click="onManualCheck()">人工对账</Button> <Button type="primary" @click="onManualCheck()">人工对账</Button>
</div> </div>
</template> </template>

View File

@@ -3,9 +3,11 @@ import type { CreateOrderType, PaymentRowType } from '../types';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { SubmitButton } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-ui'; import { VbenIcon } from '@vben-core/shadcn-ui';
import { Button, Card, Input, message, Modal, notification } from 'ant-design-vue'; import { Card, Input, message, Modal, notification } from 'ant-design-vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { reconciliateBillsApi } from '#/api/posting/reconciliate'; import { reconciliateBillsApi } from '#/api/posting/reconciliate';
@@ -171,7 +173,9 @@ function onCompleteCheck() {
}); });
// 删除当前已对账的数据 // 删除当前已对账的数据
const currentIndex = multipleCurrentIndex.value[0] || 0; const currentIndex = multipleCurrentIndex.value[0] || 0;
multipleCurrentIndex.value.forEach((index) => { // 先排序索引,从大到小删除,避免索引变化影响后续删除
const sortedIndices = multipleCurrentIndex.value.toSorted((a, b) => b - a);
sortedIndices.forEach((index) => {
pendingData.value.splice(index, 1); pendingData.value.splice(index, 1);
}); });
@@ -186,6 +190,7 @@ function onCompleteCheck() {
multipleCurrentData.value = []; multipleCurrentData.value = [];
// 如果当前索引超出了数据范围重置到第一条否则下一条删除已处理索引后currentIndex即是下一条 // 如果当前索引超出了数据范围重置到第一条否则下一条删除已处理索引后currentIndex即是下一条
multipleCurrentIndex.value[0] = Math.min(currentIndex, pendingData.value.length - 1); multipleCurrentIndex.value[0] = Math.min(currentIndex, pendingData.value.length - 1);
changePendingData(multipleCurrentIndex.value[0]);
} }
// 其他方式--确认对账 -------------- // 其他方式--确认对账 --------------
// 已选列表 // 已选列表
@@ -325,10 +330,9 @@ async function onCompleteCheckCreated() {
orders: data, orders: data,
}) })
: reconciliateBillsApi.manualCheckCreated(data)); : reconciliateBillsApi.manualCheckCreated(data));
// 刷新数据
onCompleteCheck(); onCompleteCheck();
// 清空已选列表
selectedData.value = [];
selectedRef.value?.clearData();
} }
</script> </script>
@@ -408,7 +412,12 @@ async function onCompleteCheckCreated() {
size="small" size="small"
> >
<template #extra> <template #extra>
<Button type="primary" size="small" @click="onCompleteCheckCreated()">确认对账</Button> <SubmitButton
theme="ant-design"
size="small"
:submit="onCompleteCheckCreated"
text="确认对账"
/>
</template> </template>
<Selected <Selected
ref="selectedRef" ref="selectedRef"

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form'; import type { VbenFormProps } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui'; import { Page, SubmitButton } from '@vben/common-ui';
import { Button, message, notification } from 'ant-design-vue'; import { Button, message, notification } from 'ant-design-vue';
@@ -55,6 +55,7 @@ const formOptions: VbenFormProps = {
}, },
fieldName: 'tel', fieldName: 'tel',
label: '手机号', label: '手机号',
defaultValue: '',
}, },
{ {
component: 'Select', component: 'Select',
@@ -159,6 +160,8 @@ const gridOptions: VxeTableGridOptions<RowType> = {
return await tianyibiApi.getPointOrdersList({ return await tianyibiApi.getPointOrdersList({
page: page.currentPage, page: page.currentPage,
limit: page.pageSize, limit: page.pageSize,
source: params.source || '',
type: params.type || '',
...params, ...params,
}); });
}, },
@@ -172,11 +175,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
}); });
async function onStartAutoMatch() { async function onStartAutoMatch() {
const hide = message.loading('系统自动匹配消耗中...', 0); const hide = message.loading('系统自动匹配天医币消耗中...', 0);
await tianyibiApi.autoConsumeTianyibi(); await tianyibiApi.autoConsumeTianyibi();
hide(); hide();
notification.success({ notification.success({
message: '自动匹配消耗成功', message: '自动匹配天医币消耗成功',
}); });
// 刷新表格数据 // 刷新表格数据
gridApi?.query(); gridApi?.query();
@@ -198,7 +201,8 @@ async function onConsumption(row: RowType, code: number) {
<Grid> <Grid>
<template #toolbar-actions> <template #toolbar-actions>
<div class="flex gap-2"> <div class="flex gap-2">
<Button type="primary" @click="onStartAutoMatch()">启动自动匹配消耗</Button> <!-- <Button type="primary" @click="onStartAutoMatch()">启动自动匹配消耗</Button> -->
<SubmitButton theme="ant-design" :submit="onStartAutoMatch" text="启动自动匹配消耗" />
</div> </div>
</template> </template>
<template #action="{ row }"> <template #action="{ row }">

View File

@@ -0,0 +1,122 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Loading, Page } from '@vben/common-ui';
import { Button, Card, DatePicker, Empty } from 'ant-design-vue';
const props = defineProps<{
contentItemClass?: string;
disabledDate: (d: any) => boolean;
downloadAll?: () => void;
downloadMonth?: (index: number) => void;
list: any[];
loading: boolean;
year: any;
}>();
const emit = defineEmits<{
(e: 'query'): void;
(e: 'update:year', v: any): void;
}>();
const modelYear = computed({
get: () => props.year,
set: (v) => emit('update:year', v),
});
const downloadingAll = ref(false);
const downloadingMonth = ref<boolean[]>([]);
watch(
() => props.list.length,
(len) => {
downloadingMonth.value = Array.from({ length: len }, () => false);
},
{ immediate: true },
);
const handleDownloadAll = async () => {
if (!props.downloadAll) return;
downloadingAll.value = true;
try {
await Promise.resolve(props.downloadAll());
} finally {
downloadingAll.value = false;
}
};
const handleDownloadMonth = async (index: number) => {
if (!props.downloadMonth) return;
downloadingMonth.value[index] = true;
try {
await Promise.resolve(props.downloadMonth(index));
} finally {
downloadingMonth.value[index] = false;
}
};
</script>
<template>
<Page auto-content-height>
<Loading class="flex h-full flex-col rounded-md bg-white" :spinning="loading">
<div class="search-form p-4">
<DatePicker
v-model:value="modelYear"
picker="year"
:disabled-date="disabledDate"
@change="() => emit('query')"
/>
<Button type="primary" class="ml-2" @click="emit('query')">查询</Button>
<Button
v-if="props.downloadAll"
type="link"
class="ml-2"
:loading="downloadingAll"
@click="handleDownloadAll"
>
下载全年报表
</Button>
</div>
<div class="h-2 bg-gray-100"></div>
<div class="content relative min-h-2 flex-1 px-3 py-4">
<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
v-if="props.downloadMonth"
type="link"
:loading="downloadingMonth[index]"
@click="handleDownloadMonth(index)"
>
下载报表
</Button>
</template>
<template v-if="item !== null">
<slot :item="item" :index="index" :year="modelYear"></slot>
</template>
<template v-else>
<div
v-if="loading"
class="flex min-h-12 items-center justify-center"
:class="contentItemClass"
>
努力加载中...
</div>
</template>
</Card>
</div>
</div>
</Loading>
</Page>
</template>
<style scoped lang="scss">
:deep(.ant-card-head-title) {
font-size: 16px !important;
}
</style>

View File

@@ -0,0 +1,67 @@
import type { Dayjs } from 'dayjs';
import { ref } from 'vue';
import { downloadFileFromBlobPart } from '@vben/utils';
import dayjs from 'dayjs';
export function useMonthReport<T>(options: {
downloadFile: (p: { month: string; year: number }) => Promise<any>;
fetchMonth: (p: { month: string; year: number }) => Promise<any>;
fileNameBuilder?: (year: number, month: string) => string;
normalize: (resp: any) => T;
}) {
const year = ref<Dayjs>(dayjs());
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
const list = ref<Array<null | T>>([]);
const loading = ref<boolean>(false);
const getList = async () => {
loading.value = true;
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'));
list.value = Array.from({ length: monthList.length }, () => null);
let pending = monthList.length;
monthList.forEach((month, idx) => {
options
.fetchMonth({
year: year.value.year(),
month,
})
.then((resp) => {
list.value[idx] = options.normalize(resp) as any;
})
.finally(() => {
pending -= 1;
if (pending === 0) {
loading.value = false;
}
});
});
};
// 下载报表
const downloadReport = async (index: number) => {
const month = index > 8 ? `${index + 1}` : `0${index + 1}`;
const filename =
options.fileNameBuilder?.(year.value.year(), month) ||
`报表_${year.value.year()}${month}月_文件.xlsx`;
const Blob = await options.downloadFile({
month,
year: year.value.year(),
});
downloadFileFromBlobPart({
source: Blob,
fileName: filename,
});
};
const downloadAllReport = async () => {
await Promise.all(list.value.map((_, index) => downloadReport(index)));
};
return { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport };
}

View File

@@ -1,18 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs'; import { onMounted } from 'vue';
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'; import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
const year = ref<Dayjs>(dayjs()); import { useMonthReport } from '../common/useMonthReport';
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface Report { interface Report {
incomes: Record<string, number>; incomes: Record<string, number>;
@@ -26,24 +18,12 @@ interface ReportItem {
fee: number; fee: number;
} }
// 报表数据 const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
const list = ref<Report[]>([]); useMonthReport<Report>({
const loading = ref<boolean>(false); fetchMonth: (p) => statisticsApi.getCourseStatistics(p),
const getList = async () => { downloadFile: (p) => statisticsApi.downloadReportCourse(p),
loading.value = true; fileNameBuilder: (y, m) => `课程报表_${y}${m}月_文件.xlsx`,
list.value = []; normalize: (data) => ({
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.getCourseStatistics({
year: year.value.year(),
month,
});
list.value.push({
incomes: { incomes: {
微信: 0, 微信: 0,
支付宝: 0, 支付宝: 0,
@@ -54,66 +34,31 @@ const getList = async () => {
notyet: data.notyet || 0, notyet: data.notyet || 0,
already: data.already || 0, already: data.already || 0,
now: data.now || 0, now: data.now || 0,
}),
}); });
}
} finally { const onUpdateYear = (v: any) => {
loading.value = false; year.value = v;
}
}; };
// 下载报表
// 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(() => { onMounted(() => {
getList(); getList();
}); });
</script> </script>
<template> <template>
<Page auto-content-height> <MonthReportView
<div class="flex h-full flex-col rounded-md bg-white"> :year="year"
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate" :disabled-date="disabledDate"
@change="getList" :loading="loading"
/> :list="list"
<Button type="primary" class="ml-2" @click="getList">查询</Button> :download-all="downloadAllReport"
<!-- <Button type="link" class="ml-2" @click="downloadAllReport"> :download-month="downloadReport"
下载 {{ year.year() }} 年全部天医币报表 content-item-class="h-[150px]"
</Button> --> @update:year="onUpdateYear"
</div> @query="getList"
<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 /> <template #default="{ item }">
</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="-m-2 text-[16px]">
<div class="flex"> <div class="flex">
<div class="flex-1 bg-[#F6FFF5] px-2 pb-1"> <div class="flex-1 bg-[#F6FFF5] px-2 pb-1">
@@ -140,14 +85,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template> </template>
<style scoped lang="scss"> </MonthReportView>
:deep(.ant-card-head-title) { </template>
font-size: 16px !important; <style scoped lang="scss"></style>
}
</style>

View File

@@ -1,19 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs'; import { onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import { Page, Spinner } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-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'; import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
const year = ref<Dayjs>(dayjs()); import { useMonthReport } from '../common/useMonthReport';
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface ReportItem { interface ReportItem {
type: string; type: string;
@@ -25,24 +18,12 @@ interface ReportItem {
}; };
} }
// 报表数据 const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
const list = ref<ReportItem[][]>([]); useMonthReport<ReportItem[]>({
const loading = ref<boolean>(false); fetchMonth: (p) => statisticsApi.getPhysicalStatistics(p),
const getList = async () => { downloadFile: (p) => statisticsApi.downloadReportPhysical(p),
loading.value = true; fileNameBuilder: (y, m) => `实物报表_${y}${m}月_文件.xlsx`,
list.value = []; normalize: (data) => {
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 = [ const paymentTypes = [
{ type: '微信', icon: { color: 'text-green-500', icon: 'ant-design:wechat-filled' } }, { 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-blue-500', icon: 'ant-design:alipay-outlined' } },
@@ -52,49 +33,38 @@ const getList = async () => {
icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' }, icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' },
}, },
]; ];
const report: ReportItem[] = paymentTypes.map((type) => { return paymentTypes.map((type) => {
const item = data.map.find((item: any) => item.type === type.type); const item = data.map.find((item: any) => item.type === type.type);
return { return {
type: type.type, type: type.type,
fee: item?.fee || 0, fee: item?.fee || 0,
count: item?.count || 0, count: item?.count || 0,
icon: { color: type.icon.color, icon: type.icon.icon }, icon: { color: type.icon.color, icon: type.icon.icon },
}; } as ReportItem;
});
},
}); });
list.value.push(report);
}
} finally {
loading.value = false;
}
};
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => { onMounted(() => {
getList(); getList();
}); });
</script> </script>
<template> <template>
<Page auto-content-height> <MonthReportView
<div class="flex h-full flex-col rounded-md bg-white"> :year="year"
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate" :disabled-date="disabledDate"
@change="getList" :loading="loading"
/> :list="list"
<Button type="primary" class="ml-2" @click="getList">查询</Button> :download-all="downloadAllReport"
</div> :download-month="downloadReport"
<div class="h-2 bg-gray-100"></div> @update:year="onUpdateYear"
<Spinner class="content flex-1 px-3 py-4" :spinning="loading"> @query="getList"
<div
v-if="list.length === 0 && !loading"
class="col-span-3 flex items-center justify-center"
> >
<Empty /> <template #default="{ item }">
</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="-m-2 text-[16px]">
<div class="flex flex-row py-2"> <div class="flex flex-row py-2">
<div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center"> <div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center">
@@ -107,19 +77,11 @@ onMounted(() => {
/> />
{{ payment.type }} {{ payment.type }}
</div> </div>
<!-- <div class="text-gray-500">{{ payment.count }}</div> -->
<div class="text-black">{{ payment.fee }}</div> <div class="text-black">{{ payment.fee }}</div>
</div> </div>
</div> </div>
</div> </div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template> </template>
<style scoped lang="scss"> </MonthReportView>
:deep(.ant-card-head-title) { </template>
font-size: 16px !important; <style scoped lang="scss"></style>
}
</style>

View File

@@ -1,21 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs'; import { onMounted } from 'vue';
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'; import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
const year = ref<Dayjs>(dayjs()); import { useMonthReport } from '../common/useMonthReport';
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface IncomeItem {
fee: number;
point: number;
}
interface Report { interface Report {
incomes: Record<string, number>; incomes: Record<string, IncomeItem>;
consumes: Record<string, number>; consumes: Record<string, number>;
surplus: number; surplus: number;
} }
@@ -23,31 +20,25 @@ interface Report {
interface ReportItem { interface ReportItem {
type: string; type: string;
fee: number; fee: number;
point: number;
} }
// 报表数据 const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
const list = ref<Report[]>([]); useMonthReport<Report>({
const loading = ref<boolean>(false); fetchMonth: (p) => statisticsApi.getReportTianyibi(p),
const getList = async () => { downloadFile: (p) => statisticsApi.downloadReportTianyibi(p),
loading.value = true; fileNameBuilder: (y, m) => `天医币报表_${y}${m}月_文件.xlsx`,
list.value = []; normalize: (data) => ({
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: { incomes: {
微信: 0, 微信: { fee: 0, point: 0 },
支付宝: 0, 支付宝: { fee: 0, point: 0 },
银行: 0, 银行: { fee: 0, point: 0 },
...Object.fromEntries(data.map.incomes.map((item: ReportItem) => [item.type, item.fee])), ...Object.fromEntries(
data.map.incomes.map((item: ReportItem) => [
item.type,
{ fee: item.fee, point: item.point },
]),
),
}, },
consumes: { consumes: {
实物: 0, 实物: 0,
@@ -57,73 +48,39 @@ const getList = async () => {
...Object.fromEntries(data.map.consumes.map((item: ReportItem) => [item.type, item.fee])), ...Object.fromEntries(data.map.consumes.map((item: ReportItem) => [item.type, item.fee])),
}, },
surplus: data.map.surplus, surplus: data.map.surplus,
}),
}); });
}
} finally { const onUpdateYear = (v: any) => {
loading.value = false; year.value = v;
}
}; };
// 下载报表
// 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(() => { onMounted(() => {
getList(); getList();
}); });
</script> </script>
<template> <template>
<Page auto-content-height> <MonthReportView
<div class="flex h-full flex-col rounded-md bg-white"> :year="year"
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate" :disabled-date="disabledDate"
@change="getList" :loading="loading"
/> :list="list"
<Button type="primary" class="ml-2" @click="getList">查询</Button> :download-all="downloadAllReport"
<!-- <Button type="link" class="ml-2" @click="downloadAllReport"> :download-month="downloadReport"
下载 {{ year.year() }} 年全部天医币报表 content-item-class="h-[180px]"
</Button> --> @update:year="onUpdateYear"
</div> @query="getList"
<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 /> <template #default="{ item }">
</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="-m-2 text-[16px]">
<div class="flex"> <div class="flex">
<div class="flex-1 bg-[#F6FFF5] px-2 pb-1"> <div class="min-w-[50%] bg-[#F6FFF5] px-2 pb-1">
<div class="p-1 text-center font-bold">进项</div> <div class="p-1 text-center font-bold">进项</div>
<div v-for="(fee, type) in item.incomes" :key="type" class="p-1"> <div v-for="(value, type) in item.incomes" :key="type" class="p-1">
<span class="text-gray-500">{{ type }}</span> <span class="text-gray-500">{{ type }}</span>
<span class="text-black">{{ fee }}</span> <span class="text-black">{{
`${value.fee}${!value.point || value.fee === value.point ? '' : `(天医币${value.point}`}`
}}</span>
</div> </div>
</div> </div>
<div class="flex-1 bg-[#FFFBF0] px-2 pb-1"> <div class="flex-1 bg-[#FFFBF0] px-2 pb-1">
@@ -136,14 +93,7 @@ onMounted(() => {
</div> </div>
<div class="px-2">APP用户剩余天医币 : {{ item.surplus }}</div> <div class="px-2">APP用户剩余天医币 : {{ item.surplus }}</div>
</div> </div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template> </template>
<style scoped lang="scss"> </MonthReportView>
:deep(.ant-card-head-title) { </template>
font-size: 16px !important; <style scoped lang="scss"></style>
}
</style>

View File

@@ -1,19 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs'; import { onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import { Page, Spinner } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-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'; import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
const year = ref<Dayjs>(dayjs()); import { useMonthReport } from '../common/useMonthReport';
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface ReportItem { interface ReportItem {
type: string; type: string;
@@ -25,24 +18,12 @@ interface ReportItem {
}; };
} }
// 报表数据 const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
const list = ref<ReportItem[][]>([]); useMonthReport<ReportItem[]>({
const loading = ref<boolean>(false); fetchMonth: (p) => statisticsApi.getTrainingClassStatistics(p),
const getList = async () => { downloadFile: (p) => statisticsApi.downloadReportTrainingClass(p),
loading.value = true; fileNameBuilder: (y, m) => `培训班报表_${y}${m}月_文件.xlsx`,
list.value = []; normalize: (data) => {
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 = [ const paymentTypes = [
{ type: '微信', icon: { color: 'text-green-500', icon: 'ant-design:wechat-filled' } }, { 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-blue-500', icon: 'ant-design:alipay-outlined' } },
@@ -52,49 +33,38 @@ const getList = async () => {
icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' }, icon: { color: 'text-purple-500', icon: 'ant-design:pay-circle-filled' },
}, },
]; ];
const report: ReportItem[] = paymentTypes.map((type) => { return paymentTypes.map((type) => {
const item = data.map.find((item: any) => item.type === type.type); const item = data.map.find((item: any) => item.type === type.type);
return { return {
type: type.type, type: type.type,
fee: item?.fee || 0, fee: item?.fee || 0,
count: item?.count || 0, count: item?.count || 0,
icon: { color: type.icon.color, icon: type.icon.icon }, icon: { color: type.icon.color, icon: type.icon.icon },
}; } as ReportItem;
});
},
}); });
list.value.push(report);
}
} finally {
loading.value = false;
}
};
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => { onMounted(() => {
getList(); getList();
}); });
</script> </script>
<template> <template>
<Page auto-content-height> <MonthReportView
<div class="flex h-full flex-col rounded-md bg-white"> :year="year"
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate" :disabled-date="disabledDate"
@change="getList" :loading="loading"
/> :list="list"
<Button type="primary" class="ml-2" @click="getList">查询</Button> :download-all="downloadAllReport"
</div> :download-month="downloadReport"
<div class="h-2 bg-gray-100"></div> @update:year="onUpdateYear"
<Spinner class="content flex-1 px-3 py-4" :spinning="loading"> @query="getList"
<div
v-if="list.length === 0 && !loading"
class="col-span-3 flex items-center justify-center"
> >
<Empty /> <template #default="{ item }">
</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="-m-2 text-[16px]">
<div class="flex flex-row py-2"> <div class="flex flex-row py-2">
<div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center"> <div v-for="(payment, index2) in item" :key="index2" class="flex-1 text-center">
@@ -107,20 +77,12 @@ onMounted(() => {
/> />
{{ payment.type }} {{ payment.type }}
</div> </div>
<!-- <div class="text-gray-500">{{ payment.count }}</div> -->
<div class="text-black">{{ payment.fee }}</div> <div class="text-black">{{ payment.fee }}</div>
</div> </div>
</div> </div>
</div> </div>
</Card> </template>
</div> </MonthReportView>
</Spinner>
</div>
</Page>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss"></style>
:deep(.ant-card-head-title) {
font-size: 16px !important;
}
</style>

View File

@@ -1,18 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs'; import { onMounted } from 'vue';
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'; import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
const year = ref<Dayjs>(dayjs()); import { useMonthReport } from '../common/useMonthReport';
const disabledDate = (date: Dayjs) => date.year() > dayjs().year();
interface Report { interface Report {
incomes: Record<string, number>; incomes: Record<string, number>;
@@ -26,24 +18,12 @@ interface ReportItem {
fee: number; fee: number;
} }
// 报表数据 const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
const list = ref<Report[]>([]); useMonthReport<Report>({
const loading = ref<boolean>(false); fetchMonth: (p) => statisticsApi.getVipStatistics(p),
const getList = async () => { downloadFile: (p) => statisticsApi.downloadReportVip(p),
loading.value = true; fileNameBuilder: (y, m) => `VIP报表_${y}${m}月_文件.xlsx`,
list.value = []; normalize: (data) => ({
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.getVipStatistics({
year: year.value.year(),
month,
});
list.value.push({
incomes: { incomes: {
微信: 0, 微信: 0,
支付宝: 0, 支付宝: 0,
@@ -54,66 +34,30 @@ const getList = async () => {
notyet: data.notyet || 0, notyet: data.notyet || 0,
already: data.already || 0, already: data.already || 0,
now: data.now || 0, now: data.now || 0,
}),
}); });
}
} finally { const onUpdateYear = (v: any) => {
loading.value = false; year.value = v;
}
}; };
// 下载报表
// 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(() => { onMounted(() => {
getList(); getList();
}); });
</script> </script>
<template> <template>
<Page auto-content-height> <MonthReportView
<div class="flex h-full flex-col rounded-md bg-white"> :year="year"
<div class="search-form p-4">
<DatePicker
v-model:value="year"
picker="year"
:disabled-date="disabledDate" :disabled-date="disabledDate"
@change="getList" :loading="loading"
/> :list="list"
<Button type="primary" class="ml-2" @click="getList">查询</Button> :download-all="downloadAllReport"
<!-- <Button type="link" class="ml-2" @click="downloadAllReport"> :download-month="downloadReport"
下载 {{ year.year() }} 年全部天医币报表 @update:year="onUpdateYear"
</Button> --> @query="getList"
</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 /> <template #default="{ item }">
</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="-m-2 text-[16px]">
<div class="flex"> <div class="flex">
<div class="flex-1 bg-[#F6FFF5] px-2 pb-1"> <div class="flex-1 bg-[#F6FFF5] px-2 pb-1">
@@ -140,14 +84,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</Card>
</div>
</Spinner>
</div>
</Page>
</template> </template>
<style scoped lang="scss"> </MonthReportView>
:deep(.ant-card-head-title) { </template>
font-size: 16px !important; <style scoped lang="scss"></style>
}
</style>

View File

@@ -55,10 +55,7 @@ export function downloadFileFromBase64({ fileName, source }: DownloadOptions) {
/** /**
* 通过图片 URL 下载图片文件 * 通过图片 URL 下载图片文件
*/ */
export async function downloadFileFromImageUrl({ export async function downloadFileFromImageUrl({ fileName, source }: DownloadOptions) {
fileName,
source,
}: DownloadOptions) {
const base64 = await urlToBase64(source); const base64 = await urlToBase64(source);
downloadFileFromBase64({ fileName, source: base64 }); downloadFileFromBase64({ fileName, source: base64 });
} }
@@ -87,9 +84,7 @@ export function downloadFileFromBlobPart({
}: DownloadOptions<BlobPart>): void { }: DownloadOptions<BlobPart>): void {
// 如果 data 不是 Blob则转换为 Blob // 如果 data 不是 Blob则转换为 Blob
const blob = const blob =
source instanceof Blob source instanceof Blob ? source : new Blob([source], { type: 'application/octet-stream' });
? source
: new Blob([source], { type: 'application/octet-stream' });
// 创建对象 URL 并触发下载 // 创建对象 URL 并触发下载
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);

View File

@@ -27,11 +27,15 @@ interface BasicUserInfo {
/** /**
* 用户角色 * 用户角色
*/ */
role?: string; roles?: string[];
/** /**
* 用户状态 * 用户状态
*/ */
state?: string; state?: string;
/**
* 首页路径
*/
homePath?: string;
} }
type ClassType = Array<object | string> | object | string; type ClassType = Array<object | string> | object | string;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'; import type { PrimitiveProps } from 'reka-ui';
import type { ButtonVariants, ButtonVariantSize } from './types'; import type { ButtonTheme, ButtonVariants, ButtonVariantSize } from './types';
import { cn } from '@vben-core/shared/utils'; import { cn } from '@vben-core/shared/utils';
@@ -12,6 +12,7 @@ import { buttonVariants } from './button';
interface Props extends PrimitiveProps { interface Props extends PrimitiveProps {
class?: any; class?: any;
size?: ButtonVariantSize; size?: ButtonVariantSize;
theme?: ButtonTheme;
variant?: ButtonVariants; variant?: ButtonVariants;
} }
@@ -25,7 +26,8 @@ const props = withDefaults(defineProps<Props>(), {
<Primitive <Primitive
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)" disabled
:class="cn(buttonVariants({ variant, size, theme }), props.class)"
> >
<slot></slot> <slot></slot>
</Primitive> </Primitive>

View File

@@ -6,18 +6,23 @@ export const buttonVariants = cva(
defaultVariants: { defaultVariants: {
size: 'default', size: 'default',
variant: 'default', variant: 'default',
theme: 'default',
}, },
variants: { variants: {
theme: {
'ant-design': '',
default: '',
},
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
icon: 'h-8 w-8 rounded-sm px-1 text-lg', icon: 'h-8 w-8 rounded-sm px-1 text-lg',
lg: 'h-10 rounded-md px-4', lg: 'h-10 rounded-md px-4',
sm: 'h-8 rounded-md px-2 text-xs', sm: 'h-8 rounded-md px-2 text-xs',
small: 'h-8 rounded-md px-2 text-xs',
xs: 'h-8 w-8 rounded-sm px-1 text-xs', xs: 'h-8 w-8 rounded-sm px-1 text-xs',
}, },
variant: { variant: {
default: default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover', 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
@@ -26,9 +31,20 @@ export const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
outline: outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
}, },
}, },
compoundVariants: [
{
theme: 'ant-design',
size: 'default',
class: 'h-8 rounded-md px-4 text-sm',
},
{
theme: 'ant-design',
size: 'small',
class: 'h-6 rounded-md px-2 text-sm',
},
],
}, },
); );

View File

@@ -3,6 +3,7 @@ export type ButtonVariantSize =
| 'icon' | 'icon'
| 'lg' | 'lg'
| 'sm' | 'sm'
| 'small'
| 'xs' | 'xs'
| null | null
| undefined; | undefined;
@@ -18,3 +19,5 @@ export type ButtonVariants =
| 'secondary' | 'secondary'
| null | null
| undefined; | undefined;
export type ButtonTheme = 'ant-design' | 'default';

View File

@@ -8,6 +8,7 @@ export * from './json-viewer';
export * from './loading'; export * from './loading';
export * from './page'; export * from './page';
export * from './resize'; export * from './resize';
export * from './submit-button';
export * from './tippy'; export * from './tippy';
export * from './tree'; export * from './tree';
export * from '@vben-core/form-ui'; export * from '@vben-core/form-ui';

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { ButtonTheme, ButtonVariants, ButtonVariantSize } from '@vben-core/shadcn-ui';
import { ref } from 'vue';
import { Button, VbenIcon } from '@vben-core/shadcn-ui';
interface SubmitProps {
/**
* @zh_CN 主题
*/
theme?: ButtonTheme;
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN 文字
*/
text?: string;
size?: ButtonVariantSize;
type?: ButtonVariants;
/**
* @zh_CN 提交函数
*/
submit?: () => Promise<void>;
}
defineOptions({ name: 'Loading' });
const props = defineProps<SubmitProps>();
const spinning = ref(false);
async function runSubmit() {
if (props.submit) {
spinning.value = true;
await new Promise((resolve) => setTimeout(resolve, props.minLoadingTime || 0));
await props.submit();
spinning.value = false;
}
}
</script>
<template>
<Button
:disabled="spinning"
:theme="props.theme"
:type="props.type ?? 'primary'"
:size="props.size ?? 'default'"
:class="props.class"
@click="runSubmit()"
>
<VbenIcon
v-if="spinning"
class="mr-1 animate-spin"
icon="ant-design:loading-3-quarters-outlined"
fallback
/>
{{ props.text || '提交' }}
</Button>
</template>

View File

@@ -0,0 +1 @@
export { default as SubmitButton } from './button.vue';

View File

@@ -22,13 +22,10 @@ class FileDownloader {
* @param config 配置信息,可选。 * @param config 配置信息,可选。
* @returns 如果config.responseReturn为'body'则返回Blob(默认)否则返回RequestResponse<Blob> * @returns 如果config.responseReturn为'body'则返回Blob(默认)否则返回RequestResponse<Blob>
*/ */
public async download<T = Blob>( public async download<T = Blob>(url: string, config?: DownloadRequestConfig): Promise<T> {
url: string,
config?: DownloadRequestConfig,
): Promise<T> {
const finalConfig: DownloadRequestConfig = { const finalConfig: DownloadRequestConfig = {
responseReturn: 'body', responseReturn: 'body',
method: 'GET', method: 'post',
...config, ...config,
responseType: 'blob', responseType: 'blob',
}; };

View File

@@ -10,7 +10,7 @@ interface AccessState {
/** /**
* 用户角色 * 用户角色
*/ */
userRoles: string; userRoles: string[];
} }
/** /**
@@ -22,12 +22,12 @@ export const useUserStore = defineStore('core-user', {
// 设置用户信息 // 设置用户信息
this.userInfo = userInfo; this.userInfo = userInfo;
// 设置角色信息 // 设置角色信息
const roles = userInfo?.role ?? ''; const roles = userInfo?.roles ?? [];
// 存储信息到本地 // 存储信息到本地
localStorage.setItem('userInfo', JSON.stringify(userInfo)); localStorage.setItem('userInfo', JSON.stringify(userInfo));
this.setUserRoles(roles); this.setUserRoles(roles);
}, },
setUserRoles(roles: string) { setUserRoles(roles: string[]) {
this.userRoles = roles; this.userRoles = roles;
}, },
}, },