Compare commits

..

15 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
044db57c67 feat(statistics): 新增课程报表功能
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
2026-01-16 15:17:22 +08:00
8971243f23 feat(对账): 新增批量对账功能并优化对账流程
- 添加批量对账模式,支持按金额批量选择账单
- 优化rowKey处理逻辑
- 调整对账表单验证规则,增加金额校验
- 修改时间单位从月到天,提高精确度
- 优化选中账单展示,增加标记和计数功能
- 修复SelectDropdownRender组件选项更新问题
2026-01-16 14:21:14 +08:00
32afef3e4e feat(报表): 新增VIP报表功能模块 2026-01-15 10:28:01 +08:00
dab7730216 refactor(对账): 重构充值组件命名和引用路径
将 YiluCourseRecharge 和 WumenCourseRecharge 组件重命名为 YiluRecharge 和 WumenRecharge
更新 Manual.vue 中的组件引用路径以匹配新命名
2026-01-14 17:03:34 +08:00
f1c50921de fix(对账组件): 统一清理组件中的默认时间值 2026-01-14 16:59:17 +08:00
dff302aae8 feat(statistics): 添加统计分析模块:天医币、实物、培训班。
- 新增天医币、实物和培训班报表页面
- 添加统计相关API接口
- 配置统计分析路由
- 优化SheetFooter和DialogFooter组件样式
2026-01-14 09:24:24 +08:00
77c1b37f2e feat: 更新财务系统界面和功能优化
- 替换系统管理和入账管理的图标为更合适的样式
- 优化登录请求的格式和错误处理
- 从localStorage读取用户信息初始化用户状态
- 简化仪表盘路由配置,移除多语言支持
- 调整工作台头部组件,移除不必要的统计信息
- 清理工作台页面,仅保留欢迎信息
2026-01-12 15:03:12 +08:00
54 changed files with 1577 additions and 423 deletions

View File

@@ -14,6 +14,10 @@ export namespace AuthApi {
* 最后登录时间
*/
lastTime: string;
/**
* 角色列表
*/
role: string;
}
/** 登录接口返回值 */
@@ -39,7 +43,10 @@ export namespace AuthApi {
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult<AuthApi.UserToken, AuthApi.UserInfo>>('/auth/login', data);
return requestClient.post<AuthApi.LoginResult<AuthApi.UserToken, AuthApi.UserInfo>>(
'/auth/login',
data,
);
}
/**

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

@@ -16,12 +16,19 @@ export const reconciliateBillsApi = {
},
/**
* 人工对账--添加订单后对账
* 人工对账--添加订单后对账 -- 单个
*/
manualCheckCreated: (data: any) => {
return requestClient.post('/common/payment/checkoffByAddOrder', { list: data });
},
/**
* 人工对账--添加订单后对账 -- 批量
*/
manualCheckCreatedBatch: (data: any) => {
return requestClient.post('/common/payment/checkoffByBatch', data);
},
/**
* 自动对账
*/

View File

@@ -4,6 +4,7 @@
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
@@ -39,11 +40,6 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
if (preferences.app.loginExpiredMode === 'modal' && accessStore.isAccessChecked) {
accessStore.setLoginExpired(true);
} else {
// 显示登录过期提示
message.error({
content: '您的登录状态已过期,请重新登录',
duration: 3,
});
// 短暂延迟后跳转,让用户看到提示
setTimeout(() => {
authStore.logout();
@@ -196,7 +192,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
// 如果是401错误已经在自定义拦截器中处理了这里不需要额外提示
if (responseCode === 401) {
return Promise.reject(error);
return Promise.reject($t('ui.fallback.http.unauthorized'));
}
// 如果有后端返回的错误信息,优先显示

View File

@@ -0,0 +1,120 @@
import { defaultRequestClient } from '#/api/defaultRequest';
import { requestClient } from '#/api/request';
export const statisticsApi = {
/**
* 获取天医币报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 天医币报表数据
*/
getReportTianyibi: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/pointStatistics', data);
},
/**
* 获取实物报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 实物报表数据
*/
getPhysicalStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/physicalStatistics', data);
},
/**
* 获取培训班报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 培训班报表数据
*/
getTrainingClassStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/trainingClassStatistics', data);
},
/**
* 获取VIP报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns VIP报表数据
*/
getVipStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/vipStatistics', data);
},
/**
* 获取课程报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 课程报表数据
*/
getCourseStatistics: (data: { month?: string; year: number }) => {
return requestClient.post('common/statistics/courseStatistics', data);
},
/**
* 下载天医币报表
* @param data 请求参数
* @param data.month 月份
* @param data.year 年份
* @returns 天医币报表数据
*/
downloadReportTianyibi: (data: { month?: string; year: number }) => {
return defaultRequestClient.download<Blob>('common/statistics/pointInfoExport', {
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

@@ -34,16 +34,18 @@ const VNodes = defineComponent({
});
const items = ref<{ value: string }[]>(props.options || []);
const value = ref(props.value);
// const value = computed(() => props.value || items.value[0]?.value);
const value = ref(props.value || items.value[0]?.value);
const inputRef = ref();
const inputValue = ref('');
// 监听 props.value 变化,更新本地 value
watch(
() => props.value,
() => props.options,
(newValue) => {
value.value = newValue;
value.value = props.value && props.value !== '' ? props.value : newValue.value[0]?.value;
},
{ immediate: true, deep: true },
);
// 监听本地 value 变化,触发 update:value 事件

View File

@@ -191,7 +191,7 @@ const renderEditComponent = (column: any, row: any) => {
const options = ref<any[]>([]);
// 创建一个唯一的缓存键,基于 row 的关键属性
const cacheKey = `${row.paymentId}_${row.come}_${row.orderType}_${row.courseId}`;
const cacheKey = `${row.paymentId}_${row[gridOptions.value?.cardConfig?.keyField || 'id']}`;
// 检查缓存中是否已有数据
if (selectOptionsCache[cacheKey]) {

View File

@@ -41,6 +41,11 @@ export interface CardListPagination {
export interface CardListOptions<T = any> {
/** 列配置 */
columns?: CardListColumn<T>[];
/** 卡片配置 */
cardConfig?: {
/** 卡片主键字段名 */
keyField?: string;
};
/** 数据源 */
data?: T[];
/** 是否显示卡片标题 */

View File

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

View File

@@ -1,17 +1,15 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
title: '首页',
},
name: 'Dashboard',
path: '/dashboard',
component: () => import('#/views/dashboard/analytics/index.vue'),
component: () => import('#/views/dashboard/workspace/index.vue'),
},
];

View File

@@ -3,7 +3,7 @@ import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
icon: 'ant-design:book-twotone',
keepAlive: true,
order: 1000,
title: '入账管理',

View File

@@ -0,0 +1,63 @@
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'),
},
{
meta: {
title: 'VIP报表',
keepAlive: true,
},
name: 'VipReport',
path: '/statistics/vip-report',
component: () => import('#/views/statistics/vip/report.vue'),
},
{
meta: {
title: '课程报表',
keepAlive: true,
},
name: 'CourseReport',
path: '/statistics/course-report',
component: () => import('#/views/statistics/course/report.vue'),
},
],
},
];
export default routes;

View File

@@ -3,7 +3,7 @@ import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
icon: 'ant-design:setting-twotone',
keepAlive: true,
order: 1000,
title: '系统管理',

View File

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

View File

@@ -1,250 +1,249 @@
<script lang="ts" setup>
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
// import type {
// WorkbenchProjectItem,
// WorkbenchQuickNavItem,
// WorkbenchTodoItem,
// WorkbenchTrendItem,
// } from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
// import { ref } from 'vue';
// import { useRouter } from 'vue-router';
import {
AnalysisChartCard,
WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
// import {
// AnalysisChartCard,
// WorkbenchHeader,
// WorkbenchProject,
// WorkbenchQuickNav,
// WorkbenchTodo,
// WorkbenchTrends,
// } from '@vben/common-ui';
import { WorkbenchHeader } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
// import { openWindow } from '@vben/utils';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
// import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
const userStore = useUserStore();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
},
];
// const projectItems: WorkbenchProjectItem[] = [
// {
// color: '',
// content: '不要等待机会,而要创造机会。',
// date: '2021-04-01',
// group: '开源组',
// icon: 'carbon:logo-github',
// title: 'Github',
// url: 'https://github.com',
// },
// {
// color: '#3fb27f',
// content: '现在的你决定将来的你。',
// date: '2021-04-01',
// group: '算法组',
// icon: 'ion:logo-vue',
// title: 'Vue',
// url: 'https://vuejs.org',
// },
// {
// color: '#e18525',
// content: '没有什么才能比努力更重要。',
// date: '2021-04-01',
// group: '上班摸鱼',
// icon: 'ion:logo-html5',
// title: 'Html5',
// url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
// },
// {
// color: '#bf0c2c',
// content: '热情和欲望可以突破一切难关。',
// date: '2021-04-01',
// group: 'UI',
// icon: 'ion:logo-angular',
// title: 'Angular',
// url: 'https://angular.io',
// },
// {
// color: '#00d8ff',
// content: '健康的身体是实现目标的基石。',
// date: '2021-04-01',
// group: '技术牛',
// icon: 'bx:bxl-react',
// title: 'React',
// url: 'https://reactjs.org',
// },
// {
// color: '#EBD94E',
// content: '路是走出来的,而不是空想出来的。',
// date: '2021-04-01',
// group: '架构组',
// icon: 'ion:logo-javascript',
// title: 'Js',
// url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
// },
// ];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
},
];
// const quickNavItems: WorkbenchQuickNavItem[] = [
// {
// color: '#1fdaca',
// icon: 'ion:home-outline',
// title: '首页',
// url: '/',
// },
// {
// color: '#bf0c2c',
// icon: 'ion:grid-outline',
// title: '仪表盘',
// url: '/dashboard',
// },
// {
// color: '#e18525',
// icon: 'ion:layers-outline',
// title: '组件',
// url: '/demos/features/icons',
// },
// {
// color: '#3fb27f',
// icon: 'ion:settings-outline',
// title: '系统管理',
// url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
// },
// {
// color: '#4daf1bc9',
// icon: 'ion:key-outline',
// title: '权限管理',
// url: '/demos/access/page-control',
// },
// {
// color: '#00d8ff',
// icon: 'ion:bar-chart-outline',
// title: '图表',
// url: '/analytics',
// },
// ];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
date: '刚刚',
title: '威廉',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
},
];
// const todoItems = ref<WorkbenchTodoItem[]>([
// {
// completed: false,
// content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
// date: '2024-07-30 11:00:00',
// title: '审查前端代码提交',
// },
// {
// completed: true,
// content: `检查并优化系统性能降低CPU使用率。`,
// date: '2024-07-30 11:00:00',
// title: '系统性能优化',
// },
// {
// completed: false,
// content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
// date: '2024-07-30 11:00:00',
// title: '安全检查',
// },
// {
// completed: false,
// content: `更新项目中的所有npm依赖包确保使用最新版本。`,
// date: '2024-07-30 11:00:00',
// title: '更新项目依赖',
// },
// {
// completed: false,
// content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
// date: '2024-07-30 11:00:00',
// title: '修复UI显示问题',
// },
// ]);
// const trendItems: WorkbenchTrendItem[] = [
// {
// avatar: 'svg:avatar-1',
// content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
// date: '刚刚',
// title: '威廉',
// },
// {
// avatar: 'svg:avatar-2',
// content: `关注了 <a>威廉</a> `,
// date: '1个小时前',
// title: '艾文',
// },
// {
// avatar: 'svg:avatar-3',
// content: `发布了 <a>个人动态</a> `,
// date: '1天前',
// title: '克里斯',
// },
// {
// avatar: 'svg:avatar-4',
// content: `发表文章 <a>如何编写一个Vite插件</a> `,
// date: '2天前',
// title: 'Vben',
// },
// {
// avatar: 'svg:avatar-1',
// content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
// date: '3天前',
// title: '皮特',
// },
// {
// avatar: 'svg:avatar-2',
// content: `关闭了问题 <a>如何运行项目</a> `,
// date: '1周前',
// title: '杰克',
// },
// {
// avatar: 'svg:avatar-3',
// content: `发布了 <a>个人动态</a> `,
// date: '1周前',
// title: '威廉',
// },
// {
// avatar: 'svg:avatar-4',
// content: `推送了代码到 <a>Github</a>`,
// date: '2021-04-01 20:00',
// title: '威廉',
// },
// {
// avatar: 'svg:avatar-4',
// content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
// date: '2021-03-01 20:00',
// title: 'Vben',
// },
// ];
const router = useRouter();
// const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
// function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
// if (nav.url?.startsWith('http')) {
// openWindow(nav.url);
// return;
// }
// if (nav.url?.startsWith('/')) {
// router.push(nav.url).catch((error) => {
// console.error('Navigation failed:', error);
// });
// } else {
// console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
// }
// }
</script>
<template>
<div class="p-5">
<WorkbenchHeader
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<WorkbenchHeader :avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar">
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
您好, {{ userStore.userInfo?.name || userStore.userInfo?.account }}, 欢迎登录财务系统
</template>
<template #description> 今日晴20 - 32 </template>
<!-- <template #description> 今日晴20 - 32 </template> -->
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<!-- <div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
@@ -261,6 +260,6 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<AnalyticsVisitsSource />
</AnalysisChartCard>
</div>
</div>
</div> -->
</div>
</template>

View File

@@ -15,6 +15,7 @@ interface RecommendedUser {
const props = withDefaults(
defineProps<{
errorItem: CreateOrderType[];
isBatchMode: boolean;
}>(),
{},
);
@@ -73,7 +74,7 @@ const gridOptions = computed(() => ({
paymentId: row.paymentId,
come: row.come,
orderType: row.orderType,
courseId: row.courseId,
courseId: row.courseId ?? '',
vipType: row.vipType ?? '',
})
.then(
@@ -88,6 +89,7 @@ const gridOptions = computed(() => ({
field: 'tel',
title: '用户手机号',
colSpan: 2,
show: () => !props.isBatchMode,
},
{ editRender: { name: 'Input' }, field: 'orderMoney', title: '订单金额' },
{ editRender: { name: 'Input' }, field: 'realMoney', title: '实际金额' },
@@ -99,7 +101,8 @@ const gridOptions = computed(() => ({
},
},
// 仅VIP 和课程订单 显示
show: (row: CreateOrderType) => row.orderType === '1' || row.orderType === '2',
show: (row: CreateOrderType) =>
!props.isBatchMode && (row.orderType === '1' || row.orderType === '2'),
field: 'startTime',
title: '开始时间',
},
@@ -108,13 +111,13 @@ const gridOptions = computed(() => ({
name: 'Select',
props: {
options: [
{ label: '1个月', value: '1' },
{ label: '3个月', value: '3' },
{ label: '半年', value: '6' },
{ label: '一年', value: '12' },
{ label: '两年', value: '24' },
{ label: '三年', value: '36' },
{ label: '四年', value: '48' },
{ label: '1个月', value: '30' },
{ label: '3个月', value: '90' },
{ label: '半年', value: '180' },
{ label: '一年', value: '365' },
{ label: '两年', value: '730' },
{ label: '三年', value: '1095' },
{ label: '四年', value: '1460' },
],
},
},
@@ -124,6 +127,9 @@ const gridOptions = computed(() => ({
title: '到期时间',
},
],
cardConfig: {
keyField: 'id',
},
showTitle: true,
titleField: 'productName',
gridColumns: 3,

View File

@@ -20,7 +20,8 @@ const props = withDefaults(
const emit = defineEmits(['completeCheck', 'deletedChecked']);
interface RowType {
productId: number;
id: string;
productId: string;
year: string;
title: string;
}
@@ -34,12 +35,12 @@ function transformData(rows: RowType[]) {
orderType: '4',
paymentId: props.payment?.id || '',
productName: row.title,
productId: row.productId,
productId: row.productId.slice(1),
catalogueId: '',
orderMoney: '',
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
startTime: '',
endTime: '',
}));
}

View File

@@ -35,11 +35,11 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: '',
orderMoney: '',
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
endTime: '',
vipType: row.type,
vipType: row.type.slice(1),
}));
}

View File

@@ -20,8 +20,8 @@ const props = withDefaults(
const emit = defineEmits(['completeCheck', 'deletedChecked']);
interface RowType {
courseId: number;
productId: number;
courseId: string;
productId: string;
price: number;
productName: string;
}
@@ -35,10 +35,10 @@ function transformData(rows: RowType[]) {
orderType: '2',
paymentId: props.payment?.id || '',
productName: row.productName,
productId: row.productId,
productId: row.productId.slice(1),
catalogueId: '',
orderMoney: String(row.price),
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
endTime: '',

View File

@@ -40,7 +40,7 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: row.catalogueId,
orderMoney: '',
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
endTime: '',

View File

@@ -20,7 +20,7 @@ const props = withDefaults(
const emit = defineEmits(['completeCheck', 'deletedChecked']);
interface RowType {
id: number;
id: string;
price: number;
productName: string;
}
@@ -34,10 +34,10 @@ function transformData(rows: RowType[]) {
orderType: '3',
paymentId: props.payment?.id || '',
productName: row.productName,
productId: row.id,
productId: row.id.slice(1),
catalogueId: '',
orderMoney: String(row.price),
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: '',
endTime: '',

View File

@@ -37,7 +37,7 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: '',
orderMoney: String(row.point),
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: '',
endTime: '',

View File

@@ -28,7 +28,7 @@ interface RowType {
function transformData(rows: RowType[]) {
return rows.map((row) => ({
id: row.courseId,
courseId: row.courseId || '',
courseId: row.courseId.slice(1),
tel: '',
come: '0',
orderType: '2',
@@ -37,7 +37,7 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: '',
orderMoney: '',
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
endTime: '',

View File

@@ -19,6 +19,7 @@ const props = withDefaults(
const emit = defineEmits(['completeCheck', 'deletedChecked']);
interface RowType {
oid: string;
courseFee: string;
courseId: string;
studyDays: number;
@@ -29,9 +30,9 @@ interface RowType {
function transformData(rows: RowType[]) {
return rows.map((row) => ({
id: row.courseId,
id: row.oid,
paymentId: props.payment?.id || 0,
courseId: row.courseId,
courseId: row.courseId.slice(1),
tel: row.cellPhone,
come: '0',
orderType: '2',
@@ -39,17 +40,17 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: '',
orderMoney: '',
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: row.createDate,
endTime: String(row.studyDays),
startTime: props.payment?.ctime || '',
endTime: '',
}));
}
const { Grid, cancelCheck, setChecked } = useCardListGrid<RowType, CreateOrderType>({
payment: computed(() => props.payment),
selectedData: computed(() => props.selectedData),
rowKey: 'courseId',
rowKey: 'oid',
rowKeyPrefix: props.tabKey,
columns: [
{ type: 'checkbox', width: 60 },

View File

@@ -34,12 +34,12 @@ function transformData(rows: RowType[]) {
orderType: '3',
paymentId: props.payment?.id || '',
productName: row.productName,
productId: row.id,
productId: row.id.slice(1),
catalogueId: '',
orderMoney: String(row.price),
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
startTime: '',
endTime: '',
}));
}

View File

@@ -37,9 +37,9 @@ function transformData(rows: RowType[]) {
productId: '',
catalogueId: '',
orderMoney: String(row.point),
realMoney: '',
realMoney: String(props.payment?.fee) || '',
districtMoney: '',
startTime: props.payment?.ctime || '',
startTime: '',
endTime: '',
}));
}

View File

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

View File

@@ -3,7 +3,11 @@ import type { CreateOrderType, PaymentRowType } from '../types';
import { ref, watch } from 'vue';
import { Button, Card, message, Modal, notification } from 'ant-design-vue';
import { SubmitButton } from '@vben/common-ui';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { Card, Input, message, Modal, notification } from 'ant-design-vue';
import dayjs from 'dayjs';
import { reconciliateBillsApi } from '#/api/posting/reconciliate';
@@ -11,16 +15,16 @@ import { useSysStore } from '#/store';
import Orders from '../components/Orders.vue';
import Selected from '../components/Selected.vue';
import TrainingClassProduct from '../components/TrainingClassProduct.vue';
import TrainingClass from '../components/TrainingClass.vue';
import VipProduct from '../components/VipProduct.vue';
import WumenCourseProduct from '../components/WumenCourseProduct.vue';
import WumenCourseRecharge from '../components/WumenCourseRecharge.vue';
import WumenCourseRecord from '../components/WumenCourseRecord.vue';
import WumenPhysicalProduct from '../components/WumenPhysicalProduct.vue';
import WumenRecharge from '../components/WumenRecharge.vue';
import YiluCourseProduct from '../components/YiluCourseProduct.vue';
import YiluCourseRecharge from '../components/YiluCourseRecharge.vue';
import YiluCourseRecord from '../components/YiluCourseRecord.vue';
import YiluPhysicalProduct from '../components/YiluPhysicalProduct.vue';
import YiluRecharge from '../components/YiluRecharge.vue';
const props = withDefaults(
defineProps<{
@@ -38,40 +42,72 @@ const emit = defineEmits(['update:show']);
const needCreateRecord: Record<string, any> = {
A: WumenCourseRecord,
B: WumenCourseProduct,
C: WumenCourseRecharge,
C: WumenRecharge,
D: WumenPhysicalProduct,
E: YiluCourseRecord,
F: YiluCourseProduct,
G: YiluCourseRecharge,
G: YiluRecharge,
H: YiluPhysicalProduct,
I: VipProduct,
J: TrainingClassProduct,
J: TrainingClass,
};
const sysStore = useSysStore();
// 批量模式
const isBatchMode = ref(false);
const visible = ref(props.show);
watch(visible, (val) => {
emit('update:show', val);
});
const currentIndex = ref(0);
const multipleCurrentIndex = ref<number[]>([0]);
const multipleCurrentData = ref<PaymentRowType[]>([]);
const selectedAmount = ref();
const pendingData = ref<PaymentRowType[]>([]);
// 监听props.data变化更新本地pendingData
watch(
() => props.data,
(newData) => {
pendingData.value = [...newData];
currentIndex.value = 0;
pendingData.value = newData.toSorted(
(a, b) => Number(b.orderFlag === 1) - Number(a.orderFlag === 1),
);
multipleCurrentIndex.value = [0];
},
{ immediate: true },
);
function changePendingData(index: number) {
currentIndex.value = index;
// 单个模式
multipleCurrentIndex.value = [index];
selectedData.value = [];
selectedRef.value?.clearData();
selectedAmount.value = null;
isBatchMode.value = false;
}
function changeBatchSelected() {
selectedData.value = [];
selectedRef.value?.clearData();
if (selectedAmount.value || selectedAmount.value === '0') {
// 按金额批量选中
activeTabKey.value = 'B'; // 批量模式不能选订单和记录,默认到课程商品
isBatchMode.value = true;
multipleCurrentData.value = pendingData.value.filter(
(item) => item.fee === Number.parseFloat(selectedAmount.value),
);
multipleCurrentIndex.value = multipleCurrentData.value.map((item) =>
pendingData.value.indexOf(item),
);
} else {
// 取消批量选中
activeTabKey.value = '0'; // 切换到订单列表
isBatchMode.value = false;
changePendingData(0);
}
}
const tabList = [
@@ -122,6 +158,10 @@ const tabList = [
];
const activeTabKey = ref('0');
function changeRecordTab(key: string) {
if (isBatchMode.value && ['0', 'A', 'C', 'E', 'G'].includes(key)) {
message.warning('批量模式下,不能切换到该选项');
return;
}
activeTabKey.value = key;
}
@@ -129,9 +169,15 @@ function changeRecordTab(key: string) {
function onCompleteCheck() {
notification.success({
message: '操作成功',
duration: 1,
});
// 删除当前已对账的数据
pendingData.value.splice(currentIndex.value, 1);
const currentIndex = multipleCurrentIndex.value[0] || 0;
// 先排序索引,从大到小删除,避免索引变化影响后续删除
const sortedIndices = multipleCurrentIndex.value.toSorted((a, b) => b - a);
sortedIndices.forEach((index) => {
pendingData.value.splice(index, 1);
});
// 如果没有待对账数据了,关闭弹框
if (pendingData.value.length === 0) {
@@ -139,10 +185,12 @@ function onCompleteCheck() {
return;
}
// 如果当前索引超出了数据范围,重置到第一条
if (currentIndex.value >= pendingData.value.length) {
currentIndex.value = 0;
}
// 清空待处理账单的已选状态
multipleCurrentIndex.value = [];
multipleCurrentData.value = [];
// 如果当前索引超出了数据范围重置到第一条否则下一条删除已处理索引后currentIndex即是下一条
multipleCurrentIndex.value[0] = Math.min(currentIndex, pendingData.value.length - 1);
changePendingData(multipleCurrentIndex.value[0]);
}
// 其他方式--确认对账 --------------
// 已选列表
@@ -172,29 +220,68 @@ function onDeleteChecked(ids: number[]) {
}
});
}
function completeSubmitCheck(item: CreateOrderType) {
if (!item.realMoney) {
message.error(`${item.productName}》请填写实际金额`);
return true;
}
if (!item.orderMoney) {
message.error(`${item.productName}》请填写订单金额`);
return true;
}
// 单个模式必须填手机号
if (!isBatchMode.value && !item.tel) {
message.error(`${item.productName}》请填写手机号`);
return true;
}
// 单个模式课程、vip必须填开始时间和结束时间
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.startTime) {
message.error(`${item.productName}》请填写开始时间`);
return true;
}
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.endTime) {
message.error(`${item.productName}》请填写到期时间`);
return true;
}
// 批量模式课程、vip必须填到期时间
if (isBatchMode.value && (item.orderType === '1' || item.orderType === '2') && !item.endTime) {
message.error(`${item.productName}》请填写到期时间`);
return true;
}
return false;
}
// 确认对账
function onCompleteCheckCreated() {
if (selectedData.value.length === 0) {
message.error('请选择数据');
async function onCompleteCheckCreated() {
if (multipleCurrentIndex.value.length === 0) {
message.error('请选择账单');
return;
}
if (selectedData.value.length === 0) {
message.error('请选择对账数据');
}
const selected = selectedRef.value.getData();
// 检查已选数据中是否有未填写的表单项
const emptyItem = selected.find((item: CreateOrderType) => {
if (
!item.realMoney ||
!item.orderMoney ||
!item.tel ||
((item.orderType === '1' || item.orderType === '2') && (!item.startTime || !item.endTime))
) {
return true;
}
return false;
return completeSubmitCheck(item);
});
if (emptyItem) {
errorData.value = [emptyItem];
message.error(`${emptyItem.productName}》有未填写项`);
return;
} else {
errorData.value = [];
}
// 验证订单金额不能小于实际金额
const invalidItem = selected.find((item: CreateOrderType) => {
return Number.parseFloat(item.orderMoney) < Number.parseFloat(item.realMoney);
});
if (invalidItem) {
errorData.value = [invalidItem];
message.error(`${invalidItem.productName}》订单金额不能小于实际金额`);
return;
} else {
errorData.value = [];
@@ -205,7 +292,7 @@ function onCompleteCheckCreated() {
(acc: number, item: CreateOrderType) => acc + Number.parseFloat(item.realMoney),
0,
);
if (totalRealMoney !== Number.parseFloat(pendingData.value?.[currentIndex.value]?.fee || '0')) {
if (totalRealMoney !== pendingData.value?.[multipleCurrentIndex.value[0] ?? 0]?.fee) {
message.error('已选数据填写的实际金额总和与对账单金额不一致');
return;
}
@@ -218,23 +305,34 @@ function onCompleteCheckCreated() {
).toString(),
};
// 只有VIP和课程订单才处理开始时间和结束时间
if (item.orderType === '1' || item.orderType === '2') {
// 单选模式且只有VIP和课程订单才处理开始时间和结束时间
if (!isBatchMode.value && (item.orderType === '1' || item.orderType === '2')) {
result.startTime = dayjs(item.startTime).format('YYYY-MM-DD'); // 格式化开始时间
result.endTime = dayjs(item.startTime)
.add(Number.parseFloat(item.endTime), 'month')
.add(Number.parseFloat(item.endTime), 'day')
.format('YYYY-MM-DD'); // 处理结束时间根据传入的vb时间和课程时长计算
}
// 如果是多选模式,处理相应数据
if (isBatchMode.value) {
delete result.paymentId;
result.startTime = '';
}
return result;
});
const paymentIds = multipleCurrentData.value.map((item) => item.id);
// 调用接口
reconciliateBillsApi.manualCheckCreated(data).then(() => {
onCompleteCheck();
// 清空已选列表
selectedData.value = [];
selectedRef.value?.clearData();
});
await (isBatchMode.value
? reconciliateBillsApi.manualCheckCreatedBatch({
paymentIds: paymentIds.join(','),
orders: data,
})
: reconciliateBillsApi.manualCheckCreated(data));
// 刷新数据
onCompleteCheck();
}
</script>
@@ -249,12 +347,22 @@ function onCompleteCheckCreated() {
>
<div class="flex gap-2">
<Card title="待对账列表" class="w-1/4" size="small">
<div class="flex flex-col gap-2 overflow-y-auto p-1" style="height: calc(100vh - 120px)">
<div class="flex items-center justify-between p-1 pb-2">
<Input
v-model:value="selectedAmount"
addon-before="批量选中金额"
allow-clear
class="w-60"
@change="changeBatchSelected"
/>
<div class="ml-2">已选中{{ multipleCurrentIndex.length }}</div>
</div>
<div class="flex flex-col gap-2 overflow-y-auto px-1" style="height: calc(100vh - 170px)">
<div
v-for="(item, index) in pendingData"
:key="item.id"
:class="{ active: currentIndex === index }"
class="pay-order-item"
:class="{ active: multipleCurrentIndex.includes(index) }"
class="pay-order-item relative"
@click="changePendingData(index)"
>
<div>关联sn号{{ item.relationSn }}</div>
@@ -263,12 +371,19 @@ function onCompleteCheckCreated() {
<div>金额{{ item.fee }}</div>
<div>支付方式{{ sysStore.getDictMap('payment', item.type) }}</div>
<div>时间{{ dayjs(item.ctime).format('YYYY-MM-DD HH:mm:ss') }}</div>
<VbenIcon
v-if="item.orderFlag === 1"
icon="ant-design:star-fill"
class="absolute right-1 top-1 h-5 w-5 text-red-500"
/>
</div>
</div>
</Card>
<div class="flex w-3/4 flex-1 flex-col gap-2">
<Card
:tab-list="tabList"
disabled
:active-tab-key="activeTabKey"
@tab-change="changeRecordTab"
class="order-card flex-1"
@@ -276,7 +391,7 @@ function onCompleteCheckCreated() {
>
<Orders
v-if="activeTabKey === '0'"
:payment-id="pendingData[currentIndex]?.id"
:payment-id="pendingData[multipleCurrentIndex[0] ?? 0]?.id"
@complete-check="onCompleteCheck"
/>
<component
@@ -284,7 +399,7 @@ function onCompleteCheckCreated() {
v-if="activeTabKey !== '0'"
ref="needCreateRecordRef"
:tab-key="activeTabKey"
:payment="pendingData[currentIndex]"
:payment="pendingData[multipleCurrentIndex[0] ?? 0]"
:selected-data="selectedData"
@complete-check="onSelectRecord"
@deleted-checked="onDeleteChecked"
@@ -297,9 +412,19 @@ function onCompleteCheckCreated() {
size="small"
>
<template #extra>
<Button type="primary" size="small" @click="onCompleteCheckCreated()">确认对账</Button>
<SubmitButton
theme="ant-design"
size="small"
:submit="onCompleteCheckCreated"
text="确认对账"
/>
</template>
<Selected ref="selectedRef" :error-item="errorData" @deleted-checked="onDeleteChecked" />
<Selected
ref="selectedRef"
:error-item="errorData"
:is-batch-mode="isBatchMode"
@deleted-checked="onDeleteChecked"
/>
</Card>
</div>
</div>
@@ -320,6 +445,7 @@ function onCompleteCheckCreated() {
}
:deep(.ant-card-body) {
padding: 5px !important;
padding-bottom: 10px !important;
}
.order-card {
width: 100%;

View File

@@ -2,7 +2,8 @@
export interface PaymentRowType {
checkoff: number;
ctime: string;
fee: string;
fee: number;
orderFlag: number;
financeSn: string;
id: number;
transactionSn: string;

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
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';
@@ -55,6 +55,7 @@ const formOptions: VbenFormProps = {
},
fieldName: 'tel',
label: '手机号',
defaultValue: '',
},
{
component: 'Select',
@@ -159,6 +160,8 @@ const gridOptions: VxeTableGridOptions<RowType> = {
return await tianyibiApi.getPointOrdersList({
page: page.currentPage,
limit: page.pageSize,
source: params.source || '',
type: params.type || '',
...params,
});
},
@@ -172,11 +175,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
});
async function onStartAutoMatch() {
const hide = message.loading('系统自动匹配消耗中...', 0);
const hide = message.loading('系统自动匹配天医币消耗中...', 0);
await tianyibiApi.autoConsumeTianyibi();
hide();
notification.success({
message: '自动匹配消耗成功',
message: '自动匹配天医币消耗成功',
});
// 刷新表格数据
gridApi?.query();
@@ -198,7 +201,8 @@ async function onConsumption(row: RowType, code: number) {
<Grid>
<template #toolbar-actions>
<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>
</template>
<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

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
import { useMonthReport } from '../common/useMonthReport';
interface Report {
incomes: Record<string, number>;
notyet: number;
already: number;
now: number;
}
interface ReportItem {
type: string;
fee: number;
}
const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
useMonthReport<Report>({
fetchMonth: (p) => statisticsApi.getCourseStatistics(p),
downloadFile: (p) => statisticsApi.downloadReportCourse(p),
fileNameBuilder: (y, m) => `课程报表_${y}${m}月_文件.xlsx`,
normalize: (data) => ({
incomes: {
微信: 0,
支付宝: 0,
银行: 0,
天医币: 0,
...Object.fromEntries(data.incomes.map((item: ReportItem) => [item.type, item.fee])),
},
notyet: data.notyet || 0,
already: data.already || 0,
now: data.now || 0,
}),
});
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => {
getList();
});
</script>
<template>
<MonthReportView
:year="year"
:disabled-date="disabledDate"
:loading="loading"
:list="list"
:download-all="downloadAllReport"
:download-month="downloadReport"
content-item-class="h-[150px]"
@update:year="onUpdateYear"
@query="getList"
>
<template #default="{ item }">
<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 class="p-1">
<span class="text-gray-500">已摊销</span>
<span class="text-black">{{ item.already }}</span>
</div>
<div class="p-1">
<span class="text-gray-500">月摊销</span>
<span class="text-black">{{ item.now }}</span>
</div>
<div class="p-1">
<span class="text-gray-500">剩余摊销</span>
<span class="text-black">{{ item.notyet }}</span>
</div>
</div>
</div>
</div>
</template>
</MonthReportView>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
import { useMonthReport } from '../common/useMonthReport';
interface ReportItem {
type: string;
fee: number;
count: number;
icon: {
color: string;
icon: string;
};
}
const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
useMonthReport<ReportItem[]>({
fetchMonth: (p) => statisticsApi.getPhysicalStatistics(p),
downloadFile: (p) => statisticsApi.downloadReportPhysical(p),
fileNameBuilder: (y, m) => `实物报表_${y}${m}月_文件.xlsx`,
normalize: (data) => {
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' },
},
];
return 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 },
} as ReportItem;
});
},
});
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => {
getList();
});
</script>
<template>
<MonthReportView
:year="year"
:disabled-date="disabledDate"
:loading="loading"
:list="list"
:download-all="downloadAllReport"
:download-month="downloadReport"
@update:year="onUpdateYear"
@query="getList"
>
<template #default="{ item }">
<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-black">{{ payment.fee }}</div>
</div>
</div>
</div>
</template>
</MonthReportView>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
import { useMonthReport } from '../common/useMonthReport';
interface IncomeItem {
fee: number;
point: number;
}
interface Report {
incomes: Record<string, IncomeItem>;
consumes: Record<string, number>;
surplus: number;
}
interface ReportItem {
type: string;
fee: number;
point: number;
}
const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
useMonthReport<Report>({
fetchMonth: (p) => statisticsApi.getReportTianyibi(p),
downloadFile: (p) => statisticsApi.downloadReportTianyibi(p),
fileNameBuilder: (y, m) => `天医币报表_${y}${m}月_文件.xlsx`,
normalize: (data) => ({
incomes: {
微信: { fee: 0, point: 0 },
支付宝: { fee: 0, point: 0 },
银行: { fee: 0, point: 0 },
...Object.fromEntries(
data.map.incomes.map((item: ReportItem) => [
item.type,
{ fee: item.fee, point: item.point },
]),
),
},
consumes: {
实物: 0,
培训班: 0,
vip: 0,
课程: 0,
...Object.fromEntries(data.map.consumes.map((item: ReportItem) => [item.type, item.fee])),
},
surplus: data.map.surplus,
}),
});
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => {
getList();
});
</script>
<template>
<MonthReportView
:year="year"
:disabled-date="disabledDate"
:loading="loading"
:list="list"
:download-all="downloadAllReport"
:download-month="downloadReport"
content-item-class="h-[180px]"
@update:year="onUpdateYear"
@query="getList"
>
<template #default="{ item }">
<div class="-m-2 text-[16px]">
<div class="flex">
<div class="min-w-[50%] bg-[#F6FFF5] px-2 pb-1">
<div class="p-1 text-center font-bold">进项</div>
<div v-for="(value, type) in item.incomes" :key="type" class="p-1">
<span class="text-gray-500">{{ type }}</span>
<span class="text-black">{{
`${value.fee}${!value.point || value.fee === value.point ? '' : `(天医币${value.point}`}`
}}</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>
</template>
</MonthReportView>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
import { useMonthReport } from '../common/useMonthReport';
interface ReportItem {
type: string;
fee: number;
count: number;
icon: {
color: string;
icon: string;
};
}
const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
useMonthReport<ReportItem[]>({
fetchMonth: (p) => statisticsApi.getTrainingClassStatistics(p),
downloadFile: (p) => statisticsApi.downloadReportTrainingClass(p),
fileNameBuilder: (y, m) => `培训班报表_${y}${m}月_文件.xlsx`,
normalize: (data) => {
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' },
},
];
return 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 },
} as ReportItem;
});
},
});
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => {
getList();
});
</script>
<template>
<MonthReportView
:year="year"
:disabled-date="disabledDate"
:loading="loading"
:list="list"
:download-all="downloadAllReport"
:download-month="downloadReport"
@update:year="onUpdateYear"
@query="getList"
>
<template #default="{ item }">
<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-black">{{ payment.fee }}</div>
</div>
</div>
</div>
</template>
</MonthReportView>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { statisticsApi } from '#/api/statistics';
import MonthReportView from '#/views/statistics/common/MonthReportView.vue';
import { useMonthReport } from '../common/useMonthReport';
interface Report {
incomes: Record<string, number>;
notyet: number;
already: number;
now: number;
}
interface ReportItem {
type: string;
fee: number;
}
const { year, disabledDate, list, loading, getList, downloadReport, downloadAllReport } =
useMonthReport<Report>({
fetchMonth: (p) => statisticsApi.getVipStatistics(p),
downloadFile: (p) => statisticsApi.downloadReportVip(p),
fileNameBuilder: (y, m) => `VIP报表_${y}${m}月_文件.xlsx`,
normalize: (data) => ({
incomes: {
微信: 0,
支付宝: 0,
银行: 0,
天医币: 0,
...Object.fromEntries(data.incomes.map((item: ReportItem) => [item.type, item.fee])),
},
notyet: data.notyet || 0,
already: data.already || 0,
now: data.now || 0,
}),
});
const onUpdateYear = (v: any) => {
year.value = v;
};
onMounted(() => {
getList();
});
</script>
<template>
<MonthReportView
:year="year"
:disabled-date="disabledDate"
:loading="loading"
:list="list"
:download-all="downloadAllReport"
:download-month="downloadReport"
@update:year="onUpdateYear"
@query="getList"
>
<template #default="{ item }">
<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 class="p-1">
<span class="text-gray-500">已摊销</span>
<span class="text-black">{{ item.already }}</span>
</div>
<div class="p-1">
<span class="text-gray-500">月摊销</span>
<span class="text-black">{{ item.now }}</span>
</div>
<div class="p-1">
<span class="text-gray-500">剩余摊销</span>
<span class="text-black">{{ item.notyet }}</span>
</div>
</div>
</div>
</div>
</template>
</MonthReportView>
</template>
<style scoped lang="scss"></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>

View File

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

View File

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

View File

@@ -27,19 +27,12 @@ const subMenu = useSubMenuContext();
const { parentMenu, parentPaths } = useMenu();
const active = computed(() => props.path === rootMenu?.activePath);
const menuIcon = computed(() =>
active.value ? props.activeIcon || props.icon : props.icon,
);
const menuIcon = computed(() => (active.value ? props.activeIcon || props.icon : props.icon));
const isTopLevelMenuItem = computed(
() => parentMenu.value?.type.name === 'Menu',
);
const isTopLevelMenuItem = computed(() => parentMenu.value?.type.name === 'Menu');
const collapseShowTitle = computed(
() =>
rootMenu.props?.collapseShowTitle &&
isTopLevelMenuItem.value &&
rootMenu.props.collapse,
() => rootMenu.props?.collapseShowTitle && isTopLevelMenuItem.value && rootMenu.props.collapse,
);
const showTooltip = computed(
@@ -92,11 +85,7 @@ onBeforeUnmount(() => {
role="menuitem"
@click.stop="handleClick"
>
<VbenTooltip
v-if="showTooltip"
:content-class="[rootMenu.theme]"
side="right"
>
<VbenTooltip v-if="showTooltip" :content-class="[rootMenu.theme]" side="right">
<template #trigger>
<div :class="[nsMenu.be('tooltip', 'trigger')]">
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" fallback />
@@ -109,11 +98,7 @@ onBeforeUnmount(() => {
<slot name="title"></slot>
</VbenTooltip>
<div v-show="!showTooltip" :class="[e('content')]">
<MenuBadge
v-if="rootMenu.props.mode !== 'horizontal'"
class="right-2"
v-bind="props"
/>
<MenuBadge v-if="rootMenu.props.mode !== 'horizontal'" class="right-2" v-bind="props" />
<VbenIcon :class="nsMenu.e('icon')" :icon="menuIcon" />
<slot></slot>
<slot name="title"></slot>

View File

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

View File

@@ -6,18 +6,23 @@ export const buttonVariants = cva(
defaultVariants: {
size: 'default',
variant: 'default',
theme: 'default',
},
variants: {
theme: {
'ant-design': '',
default: '',
},
size: {
default: 'h-9 px-4 py-2',
icon: 'h-8 w-8 rounded-sm px-1 text-lg',
lg: 'h-10 rounded-md px-4',
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',
},
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover',
ghost: 'hover:bg-accent hover:text-accent-foreground',
@@ -26,9 +31,20 @@ export const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
secondary: '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'
| 'lg'
| 'sm'
| 'small'
| 'xs'
| null
| undefined;
@@ -18,3 +19,5 @@ export type ButtonVariants =
| 'secondary'
| null
| undefined;
export type ButtonTheme = 'ant-design' | 'default';

View File

@@ -5,11 +5,7 @@ const props = defineProps<{ class?: any }>();
</script>
<template>
<div
:class="
cn('flex flex-row flex-col-reverse justify-end gap-x-2', props.class)
"
>
<div :class="cn('flex flex-col-reverse justify-end gap-x-2', props.class)">
<slot></slot>
</div>
</template>

View File

@@ -5,11 +5,7 @@ const props = defineProps<{ class?: any }>();
</script>
<template>
<div
:class="
cn('flex flex-row flex-col-reverse justify-end gap-x-2', props.class)
"
>
<div :class="cn('flex flex-col-reverse justify-end gap-x-2', props.class)">
<slot></slot>
</div>
</template>

View File

@@ -8,6 +8,7 @@ export * from './json-viewer';
export * from './loading';
export * from './page';
export * from './resize';
export * from './submit-button';
export * from './tippy';
export * from './tree';
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

@@ -27,17 +27,20 @@ withDefaults(defineProps<Props>(), {
<slot name="description"></slot>
</span>
</div>
<div class="mt-4 flex flex-1 justify-end md:mt-0">
<div class="flex flex-col justify-center text-right">
<div
v-if="$slots.todo || $slots.project || $slots.team"
class="mt-4 flex flex-1 justify-end md:mt-0"
>
<div v-if="$slots.todo" class="flex flex-col justify-center text-right">
<span class="text-foreground/80"> 待办 </span>
<span class="text-2xl">2/10</span>
</div>
<div class="mx-12 flex flex-col justify-center text-right md:mx-16">
<div v-if="$slots.project" class="mx-12 flex flex-col justify-center text-right md:mx-16">
<span class="text-foreground/80"> 项目 </span>
<span class="text-2xl">8</span>
</div>
<div class="mr-4 flex flex-col justify-center text-right md:mr-10">
<div v-if="$slots.team" class="mr-4 flex flex-col justify-center text-right md:mr-10">
<span class="text-foreground/80"> 团队 </span>
<span class="text-2xl">300</span>
</div>

View File

@@ -2,14 +2,7 @@
import type { Props } from './types';
import { preferences } from '@vben-core/preferences';
import {
Card,
Separator,
Tabs,
TabsList,
TabsTrigger,
VbenAvatar,
} from '@vben-core/shadcn-ui';
import { Card, Separator, Tabs, TabsList, TabsTrigger, VbenAvatar } from '@vben-core/shadcn-ui';
import { Page } from '../../components';
@@ -29,15 +22,12 @@ const tabsValue = defineModel<string>('modelValue');
<div class="flex h-full w-full">
<Card class="w-1/6 flex-none">
<div class="mt-4 flex h-40 flex-col items-center justify-center gap-4">
<VbenAvatar
:src="userInfo?.avatar ?? preferences.app.defaultAvatar"
class="size-20"
/>
<VbenAvatar :src="userInfo?.avatar ?? preferences.app.defaultAvatar" class="size-20" />
<span class="text-lg font-semibold">
{{ userInfo?.realName ?? '' }}
{{ userInfo?.name ?? '' }}
</span>
<span class="text-foreground/80 text-sm">
{{ userInfo?.username ?? '' }}
{{ userInfo?.name ?? '' }}
</span>
</div>
<Separator class="my-4" />

View File

@@ -219,10 +219,16 @@ const headerSlots = computed(() => {
:z-index="preferences.app.zIndex"
@side-mouse-leave="handleSideMouseLeave"
@toggle-sidebar="toggleSidebar"
@update:sidebar-collapse="(value: boolean) => updatePreferences({ sidebar: { collapsed: value } })"
@update:sidebar-collapse="
(value: boolean) => updatePreferences({ sidebar: { collapsed: value } })
"
@update:sidebar-enable="(value: boolean) => updatePreferences({ sidebar: { enable: value } })"
@update:sidebar-expand-on-hover="(value: boolean) => updatePreferences({ sidebar: { expandOnHover: value } })"
@update:sidebar-extra-collapse="(value: boolean) => updatePreferences({ sidebar: { extraCollapse: value } })"
@update:sidebar-expand-on-hover="
(value: boolean) => updatePreferences({ sidebar: { expandOnHover: value } })
"
@update:sidebar-extra-collapse="
(value: boolean) => updatePreferences({ sidebar: { extraCollapse: value } })
"
>
<!-- logo -->
<template #logo>
@@ -330,7 +336,11 @@ const headerSlots = computed(() => {
</template>
<template #tabbar>
<LayoutTabbar v-if="preferences.tabbar.enable" :show-icon="preferences.tabbar.showIcon" :theme="theme" />
<LayoutTabbar
v-if="preferences.tabbar.enable"
:show-icon="preferences.tabbar.showIcon"
:theme="theme"
/>
</template>
<!-- 主体内容 -->
@@ -361,7 +371,10 @@ const headerSlots = computed(() => {
</Transition>
<template v-if="preferencesButtonPosition.fixed">
<Preferences class="z-100 fixed bottom-20 right-0" @clear-preferences-and-logout="clearPreferencesAndLogout" />
<Preferences
class="z-100 fixed bottom-20 right-0"
@clear-preferences-and-logout="clearPreferencesAndLogout"
/>
</template>
<VbenBackTop />
</template>

View File

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

View File

@@ -10,7 +10,7 @@ interface AccessState {
/**
* 用户角色
*/
userRoles: string;
userRoles: string[];
}
/**
@@ -22,19 +22,27 @@ export const useUserStore = defineStore('core-user', {
// 设置用户信息
this.userInfo = userInfo;
// 设置角色信息
const roles = userInfo?.role ?? '';
const roles = userInfo?.roles ?? [];
// 存储信息到本地
localStorage.setItem('userInfo', JSON.stringify(userInfo));
this.setUserRoles(roles);
},
setUserRoles(roles: string) {
setUserRoles(roles: string[]) {
this.userRoles = roles;
},
},
state: (): AccessState => ({
userInfo: null,
userRoles: '',
}),
state: (): AccessState => {
// 从localStorage中读取用户信息
const storedUserInfo = localStorage.getItem('userInfo');
const userInfo = storedUserInfo ? JSON.parse(storedUserInfo) : null;
// 从用户信息中提取角色
const roles = userInfo?.role ?? '';
return {
userInfo,
userRoles: roles,
};
},
});
// 解决热更新问题