chore: push latest changes

This commit is contained in:
wanwu
2025-11-16 22:13:57 +08:00
parent de821ae5fd
commit 7ede50739b
780 changed files with 101983 additions and 10460 deletions

View File

@@ -93,6 +93,41 @@ export const getCustomMenuApi = () => {
return request.get('/adminapi/wechat/menu');
};
// 获取菜单列表
export const getMenuListApi = (params: { page?: number; limit?: number }) => {
return request.get('/adminapi/wechat/menu', { params });
};
// 获取菜单详情
export const getWechatMenuInfo = (id: number) => {
return request.get(`/adminapi/wechat/menu/${id}`);
};
// 创建菜单
export const createWechatMenu = (data: any) => {
return request.post('/adminapi/wechat/menu', data);
};
// 更新菜单
export const updateWechatMenu = (data: any) => {
return request.put('/adminapi/wechat/menu', data);
};
// 删除菜单
export const deleteWechatMenu = (id: number) => {
return request.delete(`/adminapi/wechat/menu/${id}`);
};
// 同步菜单
export const syncWechatMenu = () => {
return request.post('/adminapi/wechat/menu/sync');
};
// 发布菜单
export const publishWechatMenu = () => {
return request.post('/adminapi/wechat/menu/publish');
};
// 保存自定义菜单
export const saveCustomMenuApi = (data: {
button: Array<{
@@ -147,10 +182,29 @@ export const getUserListApi = (params: { page?: number; limit?: number; nickname
};
// 同步用户
export const syncUserApi = () => {
export const syncWechatUser = () => {
return request.post('/adminapi/wechat/user/sync');
};
// 导出用户
export const exportWechatUser = () => {
return request.post('/adminapi/wechat/user/export');
};
// 更新用户信息
export const updateWechatUser = (data: {
openid: string;
remark?: string;
groupid?: number;
}) => {
return request.put('/adminapi/wechat/user', data);
};
// 获取用户信息
export const getWechatUserInfo = (openid: string) => {
return request.get(`/adminapi/wechat/user/${openid}`);
};
// 获取用户详情
export const getUserDetailApi = (openid: string) => {
return request.get(`/adminapi/wechat/user/${openid}`);
@@ -161,16 +215,26 @@ export const getMaterialListApi = (params: { page?: number; limit?: number; type
return request.get('/adminapi/wechat/material', { params });
};
// 获取素材详情
export const getWechatMaterialInfo = (id: number) => {
return request.get(`/adminapi/wechat/material/${id}`);
};
// 同步素材
export const syncMaterialApi = () => {
export const syncWechatMaterial = () => {
return request.post('/adminapi/wechat/material/sync');
};
// 上传素材
export const uploadMaterialApi = (data: FormData) => {
export const uploadWechatMaterial = (data: FormData) => {
return request.post('/adminapi/wechat/material/upload', data);
};
// 更新素材
export const updateWechatMaterial = (data: any) => {
return request.put('/adminapi/wechat/material', data);
};
// 删除素材
export const deleteMaterialApi = (id: number) => {
return request.delete(`/adminapi/wechat/material/${id}`);

View File

@@ -18,6 +18,45 @@
"appList": "应用列表",
"chooseLayout": "选择布局"
},
"menu": {
"auth": "权限管理",
"user": "用户管理",
"role": "角色管理",
"menu": "菜单管理",
"site": "站点管理",
"siteGroup": "站点分组",
"diy": "DIY装修",
"channel": {
"weapp": "微信小程序",
"wechat": {
"access": "接入指引",
"config": "配置管理",
"template": "模板消息",
"menu": "自定义菜单",
"user": "用户管理",
"material": "素材管理",
"tutorial": "使用教程"
}
},
"setting": {
"system": "系统设置",
"payment": "支付设置",
"sms": "短信设置",
"storage": "存储设置"
},
"app": {
"list": "应用管理"
},
"tools": {
"backup": "数据备份"
},
"finance": {
"payment": "支付记录"
},
"log": {
"admin": "管理员日志"
}
},
"channel": {
"weapp": {
"title": "微信小程序",
@@ -56,4 +95,4 @@
"list": "存储配置"
}
}
}
}

View File

@@ -0,0 +1,218 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '@vben/locale';
const routes: RouteRecordRaw[] = [
{
path: '/auth',
name: 'Auth',
component: () => import('#/views/auth/list.vue'),
meta: {
title: $t('menu.auth'),
icon: 'mdi:account-key',
permissions: ['auth.manage'],
},
},
{
path: '/user',
name: 'User',
component: () => import('#/views/user/list.vue'),
meta: {
title: $t('menu.user'),
icon: 'mdi:account-group',
permissions: ['user.manage'],
},
},
{
path: '/role',
name: 'Role',
component: () => import('#/views/role/list.vue'),
meta: {
title: $t('menu.role'),
icon: 'mdi:shield-account',
permissions: ['role.manage'],
},
},
{
path: '/menu',
name: 'Menu',
component: () => import('#/views/menu/list.vue'),
meta: {
title: $t('menu.menu'),
icon: 'mdi:menu',
permissions: ['menu.manage'],
},
},
{
path: '/site',
name: 'Site',
component: () => import('#/views/site/list.vue'),
meta: {
title: $t('menu.site'),
icon: 'mdi:web',
permissions: ['site.manage'],
},
},
{
path: '/site-group',
name: 'SiteGroup',
component: () => import('#/views/site/group.vue'),
meta: {
title: $t('menu.siteGroup'),
icon: 'mdi:folder-multiple',
permissions: ['site.group.manage'],
},
},
{
path: '/diy',
name: 'Diy',
component: () => import('#/views/diy/list.vue'),
meta: {
title: $t('menu.diy'),
icon: 'mdi:palette',
permissions: ['diy.manage'],
},
},
{
path: '/channel/weapp',
name: 'ChannelWeapp',
component: () => import('#/views/channel/weapp/list.vue'),
meta: {
title: $t('menu.channel.weapp'),
icon: 'mdi:wechat',
permissions: ['channel.weapp.manage'],
},
},
{
path: '/channel/wechat/access',
name: 'ChannelWechatAccess',
component: () => import('#/views/channel/wechat/access/list.vue'),
meta: {
title: $t('menu.channel.wechat.access'),
icon: 'mdi:account-check',
permissions: ['channel.wechat.manage'],
},
},
{
path: '/channel/wechat/config',
name: 'ChannelWechatConfig',
component: () => import('#/views/channel/wechat/config/list.vue'),
meta: {
title: $t('menu.channel.wechat.config'),
icon: 'mdi:cog',
permissions: ['channel.wechat.manage'],
},
},
{
path: '/channel/wechat/template',
name: 'ChannelWechatTemplate',
component: () => import('#/views/channel/wechat/template/list.vue'),
meta: {
title: $t('menu.channel.wechat.template'),
icon: 'mdi:file-document',
permissions: ['channel.wechat.manage'],
},
},
{
path: '/channel/wechat/version',
name: 'ChannelWechatVersion',
component: () => import('#/views/channel/wechat/version/list.vue'),
meta: {
title: $t('menu.channel.wechat.version'),
icon: 'mdi:tag',
permissions: ['channel.wechat.manage'],
},
},
{
path: '/channel/wechat/tutorial',
name: 'ChannelWechatTutorial',
component: () => import('#/views/channel/wechat/tutorial/list.vue'),
meta: {
title: $t('menu.channel.wechat.tutorial'),
icon: 'mdi:book',
permissions: ['channel.wechat.manage'],
},
},
{
path: '/channel/wechat/menu',
name: 'ChannelWechatMenu',
component: () => import('#/views/channel/wechat/menu/list.vue'),
meta: {
title: $t('menu.channel.wechat.menu'),
icon: 'mdi:menu',
permissions: ['channel.wechat.menu.manage'],
},
},
{
path: '/channel/wechat/user',
name: 'ChannelWechatUser',
component: () => import('#/views/channel/wechat/user/list.vue'),
meta: {
title: $t('menu.channel.wechat.user'),
icon: 'mdi:account-multiple',
permissions: ['channel.wechat.user.manage'],
},
},
{
path: '/channel/wechat/material',
name: 'ChannelWechatMaterial',
component: () => import('#/views/channel/wechat/material/list.vue'),
meta: {
title: $t('menu.channel.wechat.material'),
icon: 'mdi:folder-image',
permissions: ['channel.wechat.material.manage'],
},
},
{
path: '/setting/system',
name: 'SettingSystem',
component: () => import('#/views/setting/system/list.vue'),
meta: {
title: $t('menu.setting.system'),
icon: 'mdi:cog',
permissions: ['setting.system.manage'],
},
},
{
path: '/app/list',
name: 'AppList',
component: () => import('#/views/app/list/list.vue'),
meta: {
title: $t('menu.app.list'),
icon: 'mdi:apps',
permissions: ['app.manage'],
},
},
{
path: '/tools/backup',
name: 'ToolsBackup',
component: () => import('#/views/tools/backup/list.vue'),
meta: {
title: $t('menu.tools.backup'),
icon: 'mdi:backup',
permissions: ['tools.backup.manage'],
},
},
{
path: '/finance/payment',
name: 'FinancePayment',
component: () => import('#/views/finance/payment/list.vue'),
meta: {
title: $t('menu.finance.payment'),
icon: 'mdi:cash-multiple',
permissions: ['finance.payment.manage'],
},
},
{
path: '/log/admin',
name: 'LogAdmin',
component: () => import('#/views/log/admin/list.vue'),
meta: {
title: $t('menu.log.admin'),
icon: 'mdi:file-document',
permissions: ['log.admin.manage'],
},
},
];
export default routes;

View File

@@ -0,0 +1,135 @@
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface AppInfo {
id: number;
name: string;
title: string;
description: string;
author: string;
version: string;
icon: string;
cover: string;
preview: string;
path: string;
admin_path: string;
type: 'addon' | 'module' | 'plugin';
category: string;
tags: string;
require: string;
install: 0 | 1;
status: 0 | 1;
config: string;
hooks: string;
create_time: string;
update_time: string;
}
export interface AppForm {
id?: number;
name: string;
title: string;
description: string;
author: string;
version: string;
icon: string;
cover: string;
preview: string;
path: string;
admin_path: string;
type: string;
category: string;
tags: string;
require: string;
install: 0 | 1;
status: 0 | 1;
config: string;
hooks: string;
}
export const typeOptions = [
{ label: '插件', value: 'addon' },
{ label: '模块', value: 'module' },
{ label: '应用', value: 'plugin' },
];
export const categoryOptions = [
{ label: '系统工具', value: 'system' },
{ label: '营销工具', value: 'marketing' },
{ label: '支付工具', value: 'payment' },
{ label: '物流工具', value: 'logistics' },
{ label: '客服工具', value: 'service' },
{ label: '数据分析', value: 'analytics' },
{ label: '其他', value: 'other' },
];
export const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
export const installOptions = [
{ label: '已安装', value: 1 },
{ label: '未安装', value: 0 },
];
export const gridOptions: VxeGridProps<AppInfo> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'icon', title: '图标', width: 80, formatter: ({ cellValue }) => {
return cellValue ? `<i class="${cellValue}" style="font-size: 24px;"></i>` : '';
} },
{ field: 'title', title: '应用名称', minWidth: 150 },
{ field: 'name', title: '应用标识', minWidth: 120 },
{ field: 'version', title: '版本', width: 100 },
{ field: 'author', title: '作者', width: 120 },
{ field: 'type', title: '类型', width: 100, formatter: ({ cellValue }) => {
const option = typeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'category', title: '分类', width: 100, formatter: ({ cellValue }) => {
const option = categoryOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'install', title: '安装状态', width: 100, formatter: ({ cellValue }) => {
return cellValue === 1 ? '已安装' : '未安装';
}},
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
}},
{ field: 'create_time', title: '创建时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 200,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: AppInfo) => {
// This will be handled in the component
},
options: [
{ code: 'install', text: '安装', icon: 'ant-design:download-outlined' },
{ code: 'config', text: '配置', icon: 'ant-design:setting-outlined' },
{ code: 'uninstall', text: '卸载', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
};

View File

@@ -0,0 +1,269 @@
<template>
<Page auto-content-height>
<VbenVxeGrid
ref="gridRef"
:grid-options="gridOptions"
:query-form-schema="queryFormSchema"
@toolbar-button-click="handleToolbarButtonClick"
>
<template #toolbar-tools>
<VbenButton type="primary" @click="handleInstallFromStore">
<SvgIcon icon="mdi:download" class="mr-1" />
应用商店
</VbenButton>
</template>
<template #icon="{ row }">
<img
v-if="row.icon"
:src="row.icon"
alt="应用图标"
class="w-10 h-10 rounded object-cover"
@error="(e: any) => e.target.src = 'https://via.placeholder.com/40x40'"
/>
<div v-else class="w-10 h-10 bg-gray-200 rounded flex items-center justify-center">
<SvgIcon icon="mdi:application" class="text-gray-400" />
</div>
</template>
<template #action="{ row }">
<VbenButton
v-if="row.install === 0"
size="small"
type="primary"
variant="text"
@click="handleInstall(row)"
>
安装
</VbenButton>
<template v-else>
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleConfig(row)"
>
配置
</VbenButton>
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleEdit(row)"
>
{{ $t('common.edit') }}
</VbenButton>
<VbenButton
size="small"
type="warning"
variant="text"
@click="handleUpdate(row)"
>
更新
</VbenButton>
<VbenPopconfirm
title="确定卸载该应用吗?"
@confirm="handleUninstall(row)"
>
<VbenButton
size="small"
type="danger"
variant="text"
>
卸载
</VbenButton>
</VbenPopconfirm>
</template>
</template>
</VbenVxeGrid>
<AppFormModal
v-model:visible="modalVisible"
:id="editingId"
@cancel="handleModalCancel"
@submit="handleModalSubmit"
/>
</Page>
</template>
<script lang="ts" setup>
import type { AppInfo, AppForm } from './data';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid, VbenButton, VbenPopconfirm, VbenVxeGrid } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { getAppListApi, installAppApi, uninstallAppApi, updateAppApi, getAppStoreListApi } from '#/api/core/app';
import { SvgIcon } from '#/components/icon';
import AppFormModal from './modules/form.vue';
import { gridOptions } from './data';
const router = useRouter();
const gridRef = ref();
const modalVisible = ref(false);
const editingId = ref<number | undefined>();
const queryFormSchema = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '应用标识',
},
{
component: 'Input',
fieldName: 'title',
label: '应用名称',
},
{
component: 'Select',
fieldName: 'type',
label: '应用类型',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '插件', value: 'addon' },
{ label: '模块', value: 'module' },
{ label: '应用', value: 'plugin' },
],
placeholder: '请选择应用类型',
},
},
{
component: 'Select',
fieldName: 'install',
label: '安装状态',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '已安装', value: 1 },
{ label: '未安装', value: 0 },
],
placeholder: '请选择安装状态',
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
placeholder: '请选择状态',
},
},
]);
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
queryFormSchema,
});
function handleToolbarButtonClick(event: string) {
switch (event) {
case 'add':
handleAdd();
break;
case 'refresh':
handleRefresh();
break;
case 'export':
handleExport();
break;
default:
break;
}
}
function handleAdd() {
editingId.value = undefined;
modalVisible.value = true;
}
function handleInstallFromStore() {
// Navigate to app store
router.push({ name: 'AppStore' });
}
function handleConfig(row: AppInfo) {
// Navigate to app config page
router.push({
name: 'AppConfig',
params: { id: row.id },
query: { name: row.name }
});
}
function handleEdit(row: AppInfo) {
editingId.value = row.id;
modalVisible.value = true;
}
async function handleInstall(row: AppInfo) {
try {
await installAppApi(row.id);
await handleRefresh();
$message.success('安装成功');
} catch (error) {
$message.error('安装失败');
}
}
async function handleUninstall(row: AppInfo) {
try {
await uninstallAppApi(row.id);
await handleRefresh();
$message.success('卸载成功');
} catch (error) {
$message.error('卸载失败');
}
}
async function handleUpdate(row: AppInfo) {
try {
await updateAppApi(row.id);
await handleRefresh();
$message.success('更新成功');
} catch (error) {
$message.error('更新失败');
}
}
function handleModalCancel() {
modalVisible.value = false;
editingId.value = undefined;
}
async function handleModalSubmit(data: AppForm) {
try {
if (editingId.value) {
await updateAppApi(editingId.value, data);
$message.success('更新成功');
} else {
// Create new app would be handled by app store
$message.info('请通过应用商店安装应用');
}
modalVisible.value = false;
await handleRefresh();
} catch (error) {
$message.error(editingId.value ? '更新失败' : '创建失败');
}
}
async function handleRefresh() {
await gridApi.query();
}
function handleExport() {
gridApi.exportData({
filename: '应用列表',
type: 'csv',
});
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { AppForm } from '../data';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useAppFormSchemas } from './formSchemas';
interface Props {
id?: number;
}
interface Emits {
(e: 'submit', data: AppForm): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
});
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<AppForm>({
name: '',
title: '',
description: '',
author: '',
version: '1.0.0',
icon: '',
cover: '',
preview: '',
path: '',
admin_path: '',
type: 'addon',
category: 'other',
tags: '',
require: '',
install: 0,
status: 1,
config: '',
hooks: '',
});
const formSchemas = useAppFormSchemas();
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load app data if editing
onMounted(async () => {
if (props.id) {
try {
// Load app data
const appData = await getAppDetailApi(props.id);
model.value = { ...appData };
} catch (error) {
console.error('Failed to load app data:', error);
}
}
});
</script>

View File

@@ -0,0 +1,169 @@
import type { AppForm } from '../data';
import { useVbenForm } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { typeOptions, categoryOptions, statusOptions } from '../data';
export const useAppFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '应用标识',
rules: 'required|pattern:^[a-zA-Z][a-zA-Z0-9_]*$',
componentProps: {
placeholder: '请输入应用标识(英文)',
},
},
{
component: 'Input',
fieldName: 'title',
label: '应用名称',
rules: 'required',
componentProps: {
placeholder: '请输入应用名称',
},
},
{
component: 'Textarea',
fieldName: 'description',
label: '应用描述',
componentProps: {
placeholder: '请输入应用描述',
rows: 3,
maxlength: 500,
showCount: true,
},
},
{
component: 'Input',
fieldName: 'author',
label: '作者',
componentProps: {
placeholder: '请输入作者名称',
},
},
{
component: 'Input',
fieldName: 'version',
label: '版本号',
rules: 'required',
componentProps: {
placeholder: '请输入版本号1.0.0',
},
},
{
component: 'Upload',
fieldName: 'icon',
label: '应用图标',
componentProps: {
accept: 'image/*',
maxCount: 1,
showUploadList: true,
listType: 'picture-card',
},
},
{
component: 'Upload',
fieldName: 'cover',
label: '应用封面',
componentProps: {
accept: 'image/*',
maxCount: 1,
showUploadList: true,
listType: 'picture-card',
},
},
{
component: 'Input',
fieldName: 'preview',
label: '预览图',
componentProps: {
placeholder: '请输入预览图URL',
},
},
{
component: 'Input',
fieldName: 'path',
label: '前台路径',
componentProps: {
placeholder: '请输入前台访问路径',
},
},
{
component: 'Input',
fieldName: 'admin_path',
label: '后台路径',
componentProps: {
placeholder: '请输入后台管理路径',
},
},
{
component: 'Select',
fieldName: 'type',
label: '应用类型',
rules: 'required',
componentProps: {
options: typeOptions,
placeholder: '请选择应用类型',
},
},
{
component: 'Select',
fieldName: 'category',
label: '应用分类',
rules: 'required',
componentProps: {
options: categoryOptions,
placeholder: '请选择应用分类',
},
},
{
component: 'Input',
fieldName: 'tags',
label: '应用标签',
componentProps: {
placeholder: '请输入应用标签,多个用逗号分隔',
},
},
{
component: 'Textarea',
fieldName: 'require',
label: '依赖要求',
componentProps: {
placeholder: '请输入依赖要求PHP>=7.2, MySQL>=5.7',
rows: 2,
},
},
{
component: 'Textarea',
fieldName: 'hooks',
label: '钩子配置',
componentProps: {
placeholder: '请输入钩子配置JSON格式',
rows: 3,
},
},
{
component: 'Textarea',
fieldName: 'config',
label: '配置信息',
componentProps: {
placeholder: '请输入配置信息JSON格式',
rows: 3,
},
},
{
component: 'RadioGroup',
fieldName: 'status',
label: '状态',
defaultValue: 1,
componentProps: {
options: statusOptions,
},
},
]);
return formSchemas;
};

View File

@@ -1,135 +1,92 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface MaterialItem {
export interface WechatMaterial {
id: number;
media_id: string;
type: 'image' | 'voice' | 'video' | 'news';
type: 'image' | 'voice' | 'video' | 'news' | 'thumb';
title?: string;
introduction?: string;
url: string;
thumb_url?: string;
content?: string;
digest?: string;
show_cover_pic: 0 | 1;
author?: string;
content_source_url?: string;
local_url?: string;
filename: string;
size: number;
width?: number;
height?: number;
duration?: number;
news_item?: NewsItem[];
status: 0 | 1;
create_time: string;
update_time: string;
}
export interface NewsItem {
title: string;
author: string;
digest: string;
show_cover_pic: 0 | 1;
content: string;
content_source_url: string;
thumb_media_id: string;
thumb_url: string;
url: string;
}
export interface MaterialForm {
id?: number;
type: 'image' | 'voice' | 'video' | 'news';
type: string;
title?: string;
introduction?: string;
file?: File;
news_item?: NewsItem[];
status: 0 | 1;
url?: string;
thumb_url?: string;
content?: string;
digest?: string;
show_cover_pic: 0 | 1;
author?: string;
content_source_url?: string;
}
export const materialTypeOptions = [
export const typeOptions = [
{ label: '图片', value: 'image' },
{ label: '语音', value: 'voice' },
{ label: '视频', value: 'video' },
{ label: '图文', value: 'news' },
{ label: '缩略图', value: 'thumb' },
];
export const materialTypeMap = {
image: '图片',
voice: '语音',
video: '视频',
news: '图文',
};
export const statusOptions = [
{ label: '正常', value: 1 },
{ label: '禁用', value: 0 },
export const showCoverOptions = [
{ label: '不显示', value: 0 },
{ label: '显示', value: 1 },
];
export const statusMap = {
1: '正常',
0: '禁用',
};
export const querySchema = [
{
fieldName: 'type',
label: '素材类型',
component: 'Select',
componentProps: {
options: materialTypeOptions,
placeholder: '请选择素材类型',
export const gridOptions: VxeGridProps<WechatMaterial> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'media_id', title: 'MediaID', width: 180 },
{ field: 'type', title: '类型', width: 100, formatter: ({ cellValue }) => {
const option = typeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'title', title: '标题', minWidth: 150 },
{ field: 'url', title: 'URL', minWidth: 200, showOverflow: true },
{ field: 'thumb_url', title: '缩略图', width: 120, formatter: ({ cellValue }) => {
return cellValue ? `<img src="${cellValue}" style="width: 60px; height: 60px; object-fit: cover;" />` : '';
} },
{ field: 'create_time', title: '创建时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 150,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: WechatMaterial) => {
// This will be handled in the component
},
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
{
fieldName: 'title',
label: '标题',
component: 'Input',
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: statusOptions,
placeholder: '请选择状态',
},
},
{
fieldName: 'create_time',
label: '创建时间',
component: 'DatePicker',
componentProps: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{
field: 'thumb_url',
title: '缩略图',
width: 100,
slots: { default: 'thumb' },
align: 'center',
},
{ field: 'title', title: '标题', minWidth: 150 },
{ field: 'type', title: '类型', width: 100, slots: { default: 'type' } },
{ field: 'filename', title: '文件名', minWidth: 200 },
{ field: 'size', title: '大小', width: 120, slots: { default: 'size' } },
{ field: 'width', title: '宽度', width: 100 },
{ field: 'height', title: '高度', width: 100 },
{ field: 'duration', title: '时长', width: 100, slots: { default: 'duration' } },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
field: 'action',
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
},
];
};

View File

@@ -1,235 +1,123 @@
<template>
<div>
<div class="m-4">
<VbenVxeGrid
ref="gridRef"
:form-options="formOptions"
:grid-options="gridOptions"
:grid-events="gridEvents"
:query-schema="querySchema"
title="微信素材管理"
@toolbar-button-click="handleToolbarClick"
@cell-operation-click="handleCellOperationClick"
>
<template #toolbar-tools>
<template #toolbar-buttons>
<VbenButton type="primary" @click="handleAdd">
<Plus class="mr-2 h-4 w-4" />
<template #icon>
<PlusOutlined />
</template>
上传素材
</VbenButton>
<VbenButton type="success" @click="handleSync">
<RefreshCw class="mr-2 h-4 w-4" />
<VbenButton type="default" @click="handleSync">
<template #icon>
<SyncOutlined />
</template>
同步素材
</VbenButton>
</template>
<template #thumb="{ row }">
<div class="flex justify-center">
<img
v-if="row.thumb_url"
:src="row.thumb_url"
:alt="row.title"
class="w-16 h-16 object-cover rounded"
@error="handleImageError"
/>
<div
v-else
class="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400"
>
<FileText class="w-8 h-8" />
</div>
</div>
</template>
<template #type="{ row }">
<VbenTag :type="getTypeColor(row.type)">
{{ materialTypeMap[row.type] }}
</VbenTag>
</template>
<template #size="{ row }">
{{ formatFileSize(row.size) }}
</template>
<template #duration="{ row }">
{{ row.duration ? formatDuration(row.duration) : '-' }}
</template>
<template #status="{ row }">
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
{{ statusMap[row.status] }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
type="text"
size="small"
@click="handleView(row)"
>
查看
</VbenButton>
<VbenButton
type="text"
size="small"
@click="handleEdit(row)"
>
编辑
</VbenButton>
<VbenPopconfirm
title="确定删除该素材吗?"
@confirm="handleDelete(row)"
>
<VbenButton type="text" size="small" danger>
删除
</VbenButton>
</VbenPopconfirm>
</template>
</VbenVxeGrid>
<MaterialUploadModal
v-model="uploadModalVisible"
@reload="reloadTable"
/>
<MaterialEditModal
v-model="editModalVisible"
:data="currentData"
@reload="reloadTable"
/>
<MaterialViewModal
v-model="viewModalVisible"
:data="currentData"
<MaterialForm
v-model="drawerVisible"
:material="currentMaterial"
@success="handleRefresh"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Plus, RefreshCw, FileText } from '@vben/icons';
import { ref } from 'vue';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { VbenButton } from '@vben/common-ui';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import MaterialForm from './modules/material-form.vue';
import { gridOptions, querySchema } from './data';
import { getWechatMaterialList, syncWechatMaterial, deleteWechatMaterial } from '#/api/core/wechat';
import type { WechatMaterial } from './data';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
const drawerVisible = ref(false);
const currentMaterial = ref<WechatMaterial | null>(null);
import { getWechatMaterialList, deleteWechatMaterial, syncWechatMaterial } from '#/api/core/wechat';
import MaterialUploadModal from './modules/upload-modal.vue';
import MaterialEditModal from './modules/edit-modal.vue';
import MaterialViewModal from './modules/view-modal.vue';
import type { MaterialItem } from './data';
import { columns, querySchema, materialTypeMap, statusMap } from './data';
const uploadModalVisible = ref(false);
const editModalVisible = ref(false);
const viewModalVisible = ref(false);
const currentData = ref<MaterialItem | null>(null);
const gridRef = ref();
const formOptions = computed(() => ({
schema: querySchema,
showCollapseButton: true,
fieldSize: 'medium',
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}));
const gridOptions = computed(() => ({
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
const [VbenVxeGrid, { reload }] = useVbenVxeGrid({
gridOptions,
querySchema,
queryList: async (params) => {
const { data } = await getWechatMaterialList(params);
return {
data: data.list,
total: data.total,
};
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
page: page.currentPage,
limit: page.pageSize,
...formValues,
};
return await getWechatMaterialList(params);
},
},
},
rowConfig: {
isHover: true,
},
columnConfig: {
minWidth: 100,
},
}));
});
const gridEvents = {
// 表格事件
const handleToolbarClick = (code: string) => {
switch (code) {
case 'add':
handleAdd();
break;
case 'sync':
handleSync();
break;
default:
break;
}
};
function getTypeColor(type: string) {
const colorMap: Record<string, string> = {
image: 'blue',
voice: 'green',
video: 'orange',
news: 'purple',
};
return colorMap[type] || 'default';
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
}
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden');
}
function handleAdd() {
uploadModalVisible.value = true;
}
function handleView(row: MaterialItem) {
currentData.value = row;
viewModalVisible.value = true;
}
function handleEdit(row: MaterialItem) {
currentData.value = row;
editModalVisible.value = true;
}
async function handleDelete(row: MaterialItem) {
try {
await deleteWechatMaterial(row.id);
VbenMessage.success('删除成功');
reloadTable();
} catch (error) {
VbenMessage.error('删除失败');
const handleCellOperationClick = (code: string, row: WechatMaterial) => {
switch (code) {
case 'edit':
currentMaterial.value = row;
drawerVisible.value = true;
break;
case 'delete':
handleDelete(row);
break;
default:
break;
}
}
};
async function handleSync() {
const handleAdd = () => {
currentMaterial.value = null;
drawerVisible.value = true;
};
const handleSync = async () => {
try {
message.loading('正在同步微信素材...');
await syncWechatMaterial();
VbenMessage.success('素材同步成功');
reloadTable();
message.success('微信素材同步成功');
reload();
} catch (error) {
VbenMessage.error('素材同步失败');
message.error('微信素材同步失败');
console.error('同步素材失败:', error);
}
}
};
function reloadTable() {
gridRef.value?.reload();
}
const handleDelete = async (material: WechatMaterial) => {
try {
await message.confirm('确定要删除该素材吗?', '删除确认');
message.loading('正在删除素材...');
await deleteWechatMaterial(material.id);
message.success('素材删除成功');
reload();
} catch (error) {
if (error !== 'cancel') {
message.error('素材删除失败');
console.error('删除素材失败:', error);
}
}
};
onMounted(() => {
// 初始化
});
const handleRefresh = () => {
reload();
};
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { MaterialForm } from '../data';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useMaterialFormSchemas } from './formSchemas';
interface Props {
id?: number;
materialData?: any;
}
interface Emits {
(e: 'submit', data: MaterialForm): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
materialData: undefined,
});
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<MaterialForm>({
type: 'image',
show_cover_pic: 0,
});
const formSchemas = useMaterialFormSchemas();
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load material data if editing
onMounted(async () => {
if (props.id && props.materialData) {
model.value = { ...props.materialData };
}
});
</script>

View File

@@ -0,0 +1,156 @@
import type { MaterialForm } from '../data';
import { useVbenForm } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { typeOptions, showCoverOptions } from '../data';
export const useMaterialFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Select',
fieldName: 'type',
label: '素材类型',
rules: 'required',
componentProps: {
options: typeOptions,
placeholder: '请选择素材类型',
},
},
{
component: 'Input',
fieldName: 'title',
label: '标题',
rules: computed(() => {
const form = useVbenForm().getValues();
return ['video', 'news'].includes(form.type) ? 'required' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return ['video', 'news'].includes(form.type);
}),
},
{
component: 'Textarea',
fieldName: 'introduction',
label: '简介',
componentProps: {
placeholder: '请输入简介',
maxlength: 200,
showCount: true,
rows: 3,
},
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'video';
}),
},
{
component: 'Upload',
fieldName: 'url',
label: '素材文件',
rules: 'required',
componentProps: {
accept: computed(() => {
const form = useVbenForm().getValues();
switch (form.type) {
case 'image':
return 'image/*';
case 'voice':
return 'audio/*';
case 'video':
return 'video/*';
case 'thumb':
return 'image/*';
default:
return '*';
}
}),
maxCount: 1,
showUploadList: true,
},
},
{
component: 'Upload',
fieldName: 'thumb_url',
label: '缩略图',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'video';
}),
componentProps: {
accept: 'image/*',
maxCount: 1,
showUploadList: true,
},
},
{
component: 'Textarea',
fieldName: 'content',
label: '图文内容',
rules: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news' ? 'required' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news';
}),
componentProps: {
placeholder: '请输入图文内容',
rows: 6,
},
},
{
component: 'Textarea',
fieldName: 'digest',
label: '图文摘要',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news';
}),
componentProps: {
placeholder: '请输入图文摘要',
maxlength: 120,
showCount: true,
rows: 3,
},
},
{
component: 'RadioGroup',
fieldName: 'show_cover_pic',
label: '封面显示',
defaultValue: 0,
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news';
}),
componentProps: {
options: showCoverOptions,
},
},
{
component: 'Input',
fieldName: 'author',
label: '作者',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news';
}),
},
{
component: 'Input',
fieldName: 'content_source_url',
label: '原文链接',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'news';
}),
componentProps: {
placeholder: '请输入原文链接',
},
},
]);
return formSchemas;
};

View File

@@ -0,0 +1,427 @@
<template>
<VbenDrawer
v-model:show="isShow"
:title="drawerTitle"
:loading="loading"
width="700px"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<VbenForm
v-model:model="formModel"
v-model:schema="formSchema"
:label-width="100"
@submit="handleConfirm"
>
<template #fileUpload="{ model, field }">
<div class="upload-container">
<a-upload
v-if="showFileUpload"
:file-list="fileList"
:before-upload="beforeUpload"
:accept="acceptFileTypes"
:multiple="false"
@change="handleFileChange"
>
<a-button>
<UploadOutlined />
选择文件
</a-button>
</a-upload>
<div v-if="uploadedFile" class="file-info">
<div class="file-preview">
<img
v-if="isImageType && uploadedFile.url"
:src="uploadedFile.url"
class="preview-image"
alt="预览"
/>
<div v-else class="file-icon">
<FileTextOutlined />
</div>
</div>
<div class="file-details">
<div class="file-name">{{ uploadedFile.name }}</div>
<div class="file-size">{{ formatFileSize(uploadedFile.size) }}</div>
</div>
</div>
</div>
</template>
</VbenForm>
</VbenDrawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { useVbenForm, useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { UploadOutlined, FileTextOutlined } from '@ant-design/icons-vue';
import { uploadWechatMaterial, updateWechatMaterial } from '#/api/core/wechat';
import type { WechatMaterial, MaterialForm } from '../data';
interface Props {
modelValue: boolean;
material?: WechatMaterial | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'success'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const fileList = ref<any[]>([]);
const uploadedFile = ref<any>(null);
const showFileUpload = computed(() => {
return !props.material || formModel.value.type !== props.material.type;
});
const isImageType = computed(() => {
return formModel.value.type === 'image' || formModel.value.type === 'thumb';
});
const acceptFileTypes = computed(() => {
switch (formModel.value.type) {
case 'image':
case 'thumb':
return 'image/*';
case 'voice':
return 'audio/*';
case 'video':
return 'video/*';
default:
return '*';
}
});
const drawerTitle = computed(() => {
return props.material ? '编辑素材' : '上传素材';
});
const [VbenForm, formModel, formSchema] = useVbenForm({
schema: [
{
fieldName: 'type',
label: '素材类型',
component: 'Select',
componentProps: {
placeholder: '请选择素材类型',
options: [
{ label: '图片', value: 'image' },
{ label: '语音', value: 'voice' },
{ label: '视频', value: 'video' },
{ label: '图文', value: 'news' },
{ label: '缩略图', value: 'thumb' },
],
},
rules: 'required',
},
{
fieldName: 'file',
label: '文件上传',
component: 'Slot',
slot: 'fileUpload',
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type !== 'news',
},
},
{
fieldName: 'title',
label: '标题',
component: 'Input',
componentProps: {
placeholder: '请输入标题',
maxlength: 100,
showCount: true,
},
rules: 'required',
},
{
fieldName: 'introduction',
label: '简介',
component: 'InputTextArea',
componentProps: {
placeholder: '请输入简介',
rows: 3,
maxlength: 200,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'video',
},
},
{
fieldName: 'content',
label: '内容',
component: 'InputTextArea',
componentProps: {
placeholder: '请输入图文内容',
rows: 8,
maxlength: 2000,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'news',
},
},
{
fieldName: 'digest',
label: '摘要',
component: 'InputTextArea',
componentProps: {
placeholder: '请输入摘要',
rows: 2,
maxlength: 120,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'news',
},
},
{
fieldName: 'author',
label: '作者',
component: 'Input',
componentProps: {
placeholder: '请输入作者',
maxlength: 50,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'news',
},
},
{
fieldName: 'content_source_url',
label: '原文链接',
component: 'Input',
componentProps: {
placeholder: '请输入原文链接',
maxlength: 200,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'news',
},
},
{
fieldName: 'show_cover_pic',
label: '显示封面',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '不显示', value: 0 },
{ label: '显示', value: 1 },
],
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'news',
},
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const [VbenDrawer, isShow] = useVbenDrawer({
formModel,
formSchema,
});
// 监听props变化
watch(
() => props.modelValue,
(val) => {
isShow.value = val;
if (val) {
if (props.material) {
// 编辑模式
formModel.value = {
id: props.material.id,
type: props.material.type,
title: props.material.title || '',
introduction: props.material.introduction || '',
content: props.material.content || '',
digest: props.material.digest || '',
author: props.material.author || '',
content_source_url: props.material.content_source_url || '',
show_cover_pic: props.material.show_cover_pic || 0,
};
uploadedFile.value = {
url: props.material.url,
name: props.material.title || '已上传文件',
};
} else {
// 新增模式
formModel.value = {
type: 'image',
title: '',
introduction: '',
content: '',
digest: '',
author: '',
content_source_url: '',
show_cover_pic: 0,
};
uploadedFile.value = null;
fileList.value = [];
}
}
},
{ immediate: true },
);
watch(isShow, (val) => {
emit('update:modelValue', val);
});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const beforeUpload = (file: File) => {
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('文件大小不能超过 2MB!');
return false;
}
return true;
};
const handleFileChange = (info: any) => {
const file = info.file;
if (file.status === 'done' || file.originFileObj) {
const reader = new FileReader();
reader.onload = (e) => {
uploadedFile.value = {
name: file.name,
size: file.size,
url: e.target?.result as string,
file: file.originFileObj || file,
};
};
if (isImageType.value) {
reader.readAsDataURL(file.originFileObj || file);
} else {
uploadedFile.value = {
name: file.name,
size: file.size,
file: file.originFileObj || file,
};
}
}
};
const handleConfirm = async () => {
try {
loading.value = true;
// 构建提交数据
const data: MaterialForm = {
...formModel.value,
};
// 如果有文件需要上传
if (uploadedFile.value?.file && formModel.value.type !== 'news') {
const formData = new FormData();
formData.append('file', uploadedFile.value.file);
formData.append('type', formModel.value.type);
formData.append('title', formModel.value.title);
if (formModel.value.introduction) {
formData.append('introduction', formModel.value.introduction);
}
await uploadWechatMaterial(formData);
message.success('素材上传成功');
} else if (props.material) {
// 编辑模式
await updateWechatMaterial(data);
message.success('素材更新成功');
} else if (formModel.value.type === 'news') {
// 图文消息新增
await uploadWechatMaterial(data);
message.success('图文消息创建成功');
}
emit('success');
isShow.value = false;
} catch (error) {
message.error('操作失败');
console.error('操作失败:', error);
} finally {
loading.value = false;
}
};
const handleCancel = () => {
isShow.value = false;
};
</script>
<style scoped>
.upload-container {
width: 100%;
}
.file-info {
margin-top: 16px;
padding: 16px;
background-color: #f5f5f5;
border-radius: 6px;
display: flex;
align-items: center;
gap: 16px;
}
.file-preview {
flex-shrink: 0;
}
.preview-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 4px;
}
.file-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background-color: #e6f7ff;
border-radius: 4px;
font-size: 32px;
color: #1890ff;
}
.file-details {
flex: 1;
}
.file-name {
font-weight: 500;
margin-bottom: 4px;
word-break: break-all;
}
.file-size {
color: #666;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="p-6">
<div class="mb-4">
<h3 class="text-lg font-medium mb-2">素材详情</h3>
<div class="text-sm text-gray-600">
<p><strong>MediaID:</strong> {{ materialData.media_id }}</p>
<p><strong>类型:</strong> {{ getTypeLabel(materialData.type) }}</p>
<p><strong>创建时间:</strong> {{ materialData.create_time }}</p>
</div>
</div>
<div class="mb-4">
<h4 class="font-medium mb-2">基本信息</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<div v-if="materialData.title" class="mb-2">
<strong>标题:</strong> {{ materialData.title }}
</div>
<div v-if="materialData.author" class="mb-2">
<strong>作者:</strong> {{ materialData.author }}
</div>
<div v-if="materialData.digest" class="mb-2">
<strong>摘要:</strong> {{ materialData.digest }}
</div>
<div v-if="materialData.introduction" class="mb-2">
<strong>简介:</strong> {{ materialData.introduction }}
</div>
</div>
</div>
<div v-if="materialData.type === 'image'" class="mb-4">
<h4 class="font-medium mb-2">图片预览</h4>
<div class="text-center">
<img
:src="materialData.url"
alt="图片素材"
class="max-w-md max-h-96 object-contain border rounded-lg mx-auto"
@error="(e: any) => e.target.src = 'https://via.placeholder.com/400x300'"
/>
</div>
</div>
<div v-if="materialData.type === 'video'" class="mb-4">
<h4 class="font-medium mb-2">视频预览</h4>
<div class="text-center">
<video
:src="materialData.url"
controls
class="max-w-md max-h-96 border rounded-lg mx-auto"
>
您的浏览器不支持视频播放
</video>
</div>
<div v-if="materialData.thumb_url" class="mt-2 text-center">
<p class="text-sm text-gray-600 mb-1">缩略图</p>
<img
:src="materialData.thumb_url"
alt="缩略图"
class="w-20 h-20 object-cover border rounded"
@error="(e: any) => e.target.src = 'https://via.placeholder.com/80x80'"
/>
</div>
</div>
<div v-if="materialData.type === 'voice'" class="mb-4">
<h4 class="font-medium mb-2">音频预览</h4>
<div class="text-center">
<audio
:src="materialData.url"
controls
class="max-w-md mx-auto"
>
您的浏览器不支持音频播放
</audio>
</div>
</div>
<div v-if="materialData.type === 'news'" class="mb-4">
<h4 class="font-medium mb-2">图文内容</h4>
<div class="bg-white border rounded-lg p-4">
<div v-if="materialData.show_cover_pic === 1 && materialData.url" class="mb-4">
<img
:src="materialData.url"
alt="封面图"
class="w-full h-48 object-cover rounded"
@error="(e: any) => e.target.src = 'https://via.placeholder.com/400x200'"
/>
</div>
<div
v-if="materialData.content"
class="prose max-w-none"
v-html="materialData.content"
></div>
<div v-if="materialData.content_source_url" class="mt-4">
<a
:href="materialData.content_source_url"
target="_blank"
class="text-blue-600 hover:text-blue-800 underline"
>
阅读原文
</a>
</div>
</div>
</div>
<div class="flex justify-end space-x-2">
<VbenButton @click="handleClose" variant="outline">
{{ $t('common.close') }}
</VbenButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { VbenButton } from '@vben/common-ui';
import { $t } from '@vben/locale';
interface Props {
materialData: any;
}
interface Emits {
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function getTypeLabel(type: string): string {
const typeMap: Record<string, string> = {
'image': '图片',
'voice': '语音',
'video': '视频',
'news': '图文',
'thumb': '缩略图',
};
return typeMap[type] || type;
}
function handleClose() {
emit('cancel');
}
</script>

View File

@@ -1,4 +1,4 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface MenuItem {
id: number;
@@ -6,35 +6,35 @@ export interface MenuItem {
type: 'click' | 'view' | 'miniprogram' | 'scancode_push' | 'scancode_waitmsg' | 'pic_sysphoto' | 'pic_photo_or_album' | 'pic_weixin' | 'location_select';
key?: string;
url?: string;
media_id?: string;
appid?: string;
pagepath?: string;
media_id?: string;
parent_id: number;
sort: number;
status: 0 | 1;
create_time: string;
update_time: string;
children?: MenuItem[];
}
export interface MenuForm {
id?: number;
name: string;
type: 'click' | 'view' | 'miniprogram' | 'scancode_push' | 'scancode_waitmsg' | 'pic_sysphoto' | 'pic_photo_or_album' | 'pic_weixin' | 'location_select';
type: string;
key?: string;
url?: string;
media_id?: string;
appid?: string;
pagepath?: string;
media_id?: string;
parent_id: number;
sort: number;
status: 0 | 1;
}
export const menuTypeOptions = [
export const typeOptions = [
{ label: '点击推事件', value: 'click' },
{ label: '跳转URL', value: 'view' },
{ label: '扫码推事件', value: 'scancode_push' },
{ label: '扫码推事件且弹出提示', value: 'scancode_waitmsg' },
{ label: '扫码推事件且弹出消息接收中', value: 'scancode_waitmsg' },
{ label: '弹出系统拍照发图', value: 'pic_sysphoto' },
{ label: '弹出拍照或者相册发图', value: 'pic_photo_or_album' },
{ label: '弹出微信相册发图器', value: 'pic_weixin' },
@@ -42,70 +42,60 @@ export const menuTypeOptions = [
{ label: '跳转小程序', value: 'miniprogram' },
];
export const menuTypeMap = {
click: '点击推事件',
view: '跳转URL',
scancode_push: '扫码推事件',
scancode_waitmsg: '扫码推事件且弹出提示',
pic_sysphoto: '弹出系统拍照发图',
pic_photo_or_album: '弹出拍照或者相册发图',
pic_weixin: '弹出微信相册发图器',
location_select: '弹出地理位置选择器',
miniprogram: '跳转小程序',
};
export const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
export const statusMap = {
1: '启用',
0: '禁用',
};
export const querySchema = [
{
fieldName: 'name',
label: '菜单名称',
component: 'Input',
export const gridOptions: VxeGridProps<MenuItem> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'name', title: '菜单名称', minWidth: 150, treeNode: true },
{ field: 'type', title: '菜单类型', width: 120, formatter: ({ cellValue }) => {
const option = typeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'key', title: '菜单KEY', width: 150 },
{ field: 'url', title: '跳转URL', minWidth: 200 },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
}},
{ field: 'create_time', title: '创建时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 150,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: MenuItem) => {
// This will be handled in the component
},
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
{
fieldName: 'type',
label: '菜单类型',
component: 'Select',
componentProps: {
options: menuTypeOptions,
placeholder: '请选择菜单类型',
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// This will be implemented in the component
return { rows: [], total: 0 };
},
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: statusOptions,
placeholder: '请选择状态',
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'name', title: '菜单名称', minWidth: 150 },
{ field: 'type', title: '菜单类型', width: 150, slots: { default: 'menuType' } },
{ field: 'key', title: '菜单KEY', width: 150 },
{ field: 'url', title: '跳转URL', minWidth: 200, showOverflow: true },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
field: 'action',
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
},
];
};

View File

@@ -1,176 +1,156 @@
<template>
<div>
<div class="h-full">
<VbenVxeGrid
ref="gridRef"
:form-options="formOptions"
:grid-options="gridOptions"
:grid-events="gridEvents"
:query-schema="querySchema"
title="自定义菜单管理"
@toolbar-button-click="handleToolbarClick"
>
<template #toolbar-tools>
<VbenButton type="primary" @click="handleAdd">
<Plus class="mr-2 h-4 w-4" />
新增菜单
</VbenButton>
<VbenButton type="success" @click="handlePublish">
<Upload class="mr-2 h-4 w-4" />
<Button type="primary" @click="handleSync">
<Icon icon="ant-design:sync-outlined" />
同步菜单
</Button>
<Button type="primary" @click="handlePublish">
<Icon icon="ant-design:cloud-upload-outlined" />
发布菜单
</VbenButton>
</template>
<template #menuType="{ row }">
<VbenTag :type="getMenuTypeColor(row.type)">
{{ menuTypeMap[row.type] }}
</VbenTag>
</template>
<template #status="{ row }">
<VbenTag :type="row.status === 1 ? 'success' : 'error'">
{{ statusMap[row.status] }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
type="text"
size="small"
@click="handleEdit(row)"
>
编辑
</VbenButton>
<VbenPopconfirm
title="确定删除该菜单吗?"
@confirm="handleDelete(row)"
>
<VbenButton type="text" size="small" danger>
删除
</VbenButton>
</VbenPopconfirm>
</Button>
</template>
</VbenVxeGrid>
<MenuEditModal
v-model="modalVisible"
:data="currentData"
:parent-menus="parentMenus"
@reload="reloadTable"
/>
<VbenDrawer
v-model:show="drawerShow"
:title="drawerTitle"
:width="800"
>
<MenuForm
:id="currentId"
@success="handleSuccess"
@cancel="handleCancel"
/>
</VbenDrawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Plus, Upload } from '@vben/icons';
import { ref } from 'vue';
import { Button } from 'ant-design-vue';
import { Icon } from '@iconify/vue';
import { useVbenVxeGrid, VbenDrawer } from '#/adapter';
import { useVbenForm } from '#/adapter/form';
import { $t } from '#/locales';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { VbenButton, VbenMessage, VbenPopconfirm, VbenTag } from '@vben/common-ui';
import { gridOptions } from './data';
import MenuForm from './modules/menu-form.vue';
import { deleteWechatMenu, syncWechatMenu, publishWechatMenu } from '#/api';
import { getWechatMenuList, deleteWechatMenu, publishWechatMenu } from '#/api/core/wechat';
import MenuEditModal from './modules/menu-edit.vue';
const drawerShow = ref(false);
const drawerTitle = ref('');
const currentId = ref<number | null>(null);
import type { MenuItem } from './data';
import { columns, querySchema, menuTypeMap, statusMap } from './data';
const modalVisible = ref(false);
const currentData = ref<MenuItem | null>(null);
const menuList = ref<MenuItem[]>([]);
const gridRef = ref();
const formOptions = computed(() => ({
schema: querySchema,
showCollapseButton: false,
fieldSize: 'medium',
wrapperClass: 'grid-cols-1 md:grid-cols-3 lg:grid-cols-4',
}));
const gridOptions = computed(() => ({
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
page: page.currentPage,
limit: page.pageSize,
...formValues,
};
const response = await getWechatMenuList(params);
menuList.value = response.data;
return response;
},
const querySchema = [
{
fieldName: 'name',
label: '菜单名称',
component: 'Input',
componentProps: {
placeholder: '请输入菜单名称',
},
},
rowConfig: {
isHover: true,
{
fieldName: 'type',
label: '菜单类型',
component: 'Select',
componentProps: {
placeholder: '请选择菜单类型',
options: [
{ label: '点击推事件', value: 'click' },
{ label: '跳转URL', value: 'view' },
{ label: '扫码推事件', value: 'scancode_push' },
{ label: '扫码推事件且弹出消息接收中', value: 'scancode_waitmsg' },
{ label: '弹出系统拍照发图', value: 'pic_sysphoto' },
{ label: '弹出拍照或者相册发图', value: 'pic_photo_or_album' },
{ label: '弹出微信相册发图器', value: 'pic_weixin' },
{ label: '弹出地理位置选择器', value: 'location_select' },
{ label: '跳转小程序', value: 'miniprogram' },
],
},
},
columnConfig: {
minWidth: 100,
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
}));
];
const gridEvents = {
// 表格事件
};
const parentMenus = computed(() => {
return menuList.value.filter(item => item.parent_id === 0);
});
function getMenuTypeColor(type: string) {
const colorMap: Record<string, string> = {
click: 'blue',
view: 'green',
miniprogram: 'orange',
scancode_push: 'purple',
scancode_waitmsg: 'purple',
pic_sysphoto: 'pink',
pic_photo_or_album: 'pink',
pic_weixin: 'pink',
location_select: 'cyan',
};
return colorMap[type] || 'default';
function handleToolbarClick(code: string, row: any) {
switch (code) {
case 'add':
handleAdd();
break;
case 'edit':
handleEdit(row);
break;
case 'delete':
handleDelete(row);
break;
default:
break;
}
}
function handleAdd() {
currentData.value = null;
modalVisible.value = true;
drawerTitle.value = '新增菜单';
currentId.value = null;
drawerShow.value = true;
}
function handleEdit(row: MenuItem) {
currentData.value = row;
modalVisible.value = true;
function handleEdit(row: any) {
drawerTitle.value = '编辑菜单';
currentId.value = row.id;
drawerShow.value = true;
}
async function handleDelete(row: MenuItem) {
async function handleDelete(row: any) {
try {
await deleteWechatMenu(row.id);
VbenMessage.success('删除成功');
reloadTable();
// Refresh grid
} catch (error) {
VbenMessage.error('删除失败');
console.error('删除菜单失败:', error);
}
}
async function handleSync() {
try {
await syncWechatMenu();
// Refresh grid
} catch (error) {
console.error('同步菜单失败:', error);
}
}
async function handlePublish() {
try {
await publishWechatMenu();
VbenMessage.success('菜单发布成功');
// Refresh grid
} catch (error) {
VbenMessage.error('菜单发布失败');
console.error('发布菜单失败:', error);
}
}
function reloadTable() {
gridRef.value?.reload();
function handleSuccess() {
drawerShow.value = false;
// Refresh grid
}
onMounted(() => {
// 初始化
});
function handleCancel() {
drawerShow.value = false;
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { MenuForm } from '../data';
import { computed } from 'vue';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useMenuFormSchemas } from './formSchemas';
interface Props {
id?: number;
menuTree: any[];
}
interface Emits {
(e: 'submit', data: MenuForm): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
});
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<MenuForm>({
name: '',
type: 'click',
parent_id: 0,
sort: 0,
status: 1,
});
const formSchemas = useMenuFormSchemas();
const treeData = computed(() => {
const tree = props.menuTree.map(item => ({
title: item.name,
value: item.id,
key: item.id,
children: item.children?.map(child => ({
title: child.name,
value: child.id,
key: child.id,
})) || [],
}));
return [
{ title: '顶级菜单', value: 0, key: 0 },
...tree,
];
});
// Update form schemas with dynamic tree data
watchEffect(() => {
const schemas = formSchemas.value;
const parentSchema = schemas.find(schema => schema.fieldName === 'parent_id');
if (parentSchema && parentSchema.componentProps) {
parentSchema.componentProps.treeData = treeData.value;
}
});
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load menu data if editing
onMounted(async () => {
if (props.id) {
try {
// Load menu data
const menuData = await getWechatMenuDetailApi(props.id);
model.value = { ...menuData };
} catch (error) {
console.error('Failed to load menu data:', error);
}
}
});
</script>

View File

@@ -0,0 +1,111 @@
import type { MenuForm, MenuItem } from './data';
import { useVbenForm } from '@vben/common-ui';
import { getI18nOptions } from '@vben/locale';
import { $t } from '@vben/locale';
import { typeOptions, statusOptions } from './data';
export const useMenuFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '菜单名称',
rules: 'required',
},
{
component: 'Select',
fieldName: 'type',
label: '菜单类型',
rules: 'required',
componentProps: {
options: typeOptions,
placeholder: '请选择菜单类型',
},
},
{
component: 'Input',
fieldName: 'key',
label: '菜单KEY',
rules: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'click' ? 'required' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return ['click', 'scancode_push', 'scancode_waitmsg', 'pic_sysphoto', 'pic_photo_or_album', 'pic_weixin', 'location_select'].includes(form.type);
}),
},
{
component: 'Input',
fieldName: 'url',
label: '跳转URL',
rules: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'view' ? 'required|url' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'view';
}),
},
{
component: 'Input',
fieldName: 'appid',
label: '小程序AppID',
rules: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'miniprogram' ? 'required' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'miniprogram';
}),
},
{
component: 'Input',
fieldName: 'pagepath',
label: '小程序页面路径',
rules: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'miniprogram' ? 'required' : '';
}),
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'miniprogram';
}),
},
{
component: 'TreeSelect',
fieldName: 'parent_id',
label: '上级菜单',
componentProps: {
placeholder: '请选择上级菜单',
treeDefaultExpandAll: false,
allowClear: true,
},
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
defaultValue: 0,
componentProps: {
min: 0,
max: 999,
},
},
{
component: 'RadioGroup',
fieldName: 'status',
label: '状态',
defaultValue: 1,
componentProps: {
options: statusOptions,
},
},
]);
return formSchemas;
};

View File

@@ -0,0 +1,225 @@
<template>
<VbenForm
:schema="formSchema"
:handle-submit="handleSubmit"
:submit-button-options="{ text: '保存' }"
:reset-button-options="{ show: false }"
wrapper-class="!grid-cols-1 md:!grid-cols-2"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { $t } from '#/locales';
import { getWechatMenuInfo, createWechatMenu, updateWechatMenu } from '#/api';
interface Props {
id?: number | null;
}
const props = withDefaults(defineProps<Props>(), {
id: null,
});
const emit = defineEmits<{
success: [];
cancel: [];
}>();
const loading = ref(false);
const menuInfo = ref<any>(null);
const typeOptions = [
{ label: '点击推事件', value: 'click' },
{ label: '跳转URL', value: 'view' },
{ label: '扫码推事件', value: 'scancode_push' },
{ label: '扫码推事件且弹出消息接收中', value: 'scancode_waitmsg' },
{ label: '弹出系统拍照发图', value: 'pic_sysphoto' },
{ label: '弹出拍照或者相册发图', value: 'pic_photo_or_album' },
{ label: '弹出微信相册发图器', value: 'pic_weixin' },
{ label: '弹出地理位置选择器', value: 'location_select' },
{ label: '跳转小程序', value: 'miniprogram' },
];
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
const formSchema = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '菜单名称',
rules: 'required|max:16',
componentProps: {
placeholder: '请输入菜单名称不超过16个字节',
maxLength: 16,
showCount: true,
},
},
{
component: 'Select',
fieldName: 'type',
label: '菜单类型',
rules: 'required',
componentProps: {
placeholder: '请选择菜单类型',
options: typeOptions,
},
},
{
component: 'Input',
fieldName: 'key',
label: '菜单KEY',
rules: 'max:128',
componentProps: {
placeholder: '请输入菜单KEY不超过128字节',
maxLength: 128,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'click',
},
},
{
component: 'Input',
fieldName: 'url',
label: '跳转URL',
rules: 'required|url|max:1024',
componentProps: {
placeholder: '请输入跳转URL不超过1024字节',
maxLength: 1024,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'view',
},
},
{
component: 'Input',
fieldName: 'media_id',
label: '媒体ID',
rules: 'max:64',
componentProps: {
placeholder: '请输入媒体ID不超过64字节',
maxLength: 64,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => ['pic_sysphoto', 'pic_photo_or_album', 'pic_weixin'].includes(type),
},
},
{
component: 'Input',
fieldName: 'appid',
label: '小程序APPID',
rules: 'max:32',
componentProps: {
placeholder: '请输入小程序APPID不超过32字节',
maxLength: 32,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'miniprogram',
},
},
{
component: 'Input',
fieldName: 'pagepath',
label: '小程序页面路径',
rules: 'max:128',
componentProps: {
placeholder: '请输入小程序页面路径不超过128字节',
maxLength: 128,
showCount: true,
},
dependencies: {
triggerFields: ['type'],
if: ({ type }) => type === 'miniprogram',
},
},
{
component: 'TreeSelect',
fieldName: 'parent_id',
label: '上级菜单',
componentProps: {
placeholder: '请选择上级菜单,不选则为一级菜单',
treeData: [], // This will be loaded from API
fieldNames: {
label: 'name',
value: 'id',
},
allowClear: true,
},
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
rules: 'required|integer|min:0',
componentProps: {
placeholder: '请输入排序',
min: 0,
max: 999,
},
},
{
component: 'RadioGroup',
fieldName: 'status',
label: '状态',
rules: 'required',
defaultValue: 1,
componentProps: {
options: statusOptions,
},
},
]);
async function loadMenuInfo() {
if (!props.id) return;
try {
loading.value = true;
const res = await getWechatMenuInfo(props.id);
menuInfo.value = res.data;
} catch (error) {
message.error('获取菜单信息失败');
} finally {
loading.value = false;
}
}
async function handleSubmit(values: any) {
try {
loading.value = true;
const data = {
...values,
id: props.id,
};
if (props.id) {
await updateWechatMenu(data);
message.success('更新菜单成功');
} else {
await createWechatMenu(data);
message.success('创建菜单成功');
}
emit('success');
} catch (error) {
message.error(props.id ? '更新菜单失败' : '创建菜单失败');
} finally {
loading.value = false;
}
}
watch(() => props.id, loadMenuInfo, { immediate: true });
</script>

View File

@@ -1,33 +1,32 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface UserItem {
export interface WechatUser {
id: number;
openid: string;
nickname: string;
headimgurl: string;
sex: 0 | 1 | 2; // 0:未知, 1:男, 2:女
language: string;
city: string;
province: string;
country: string;
headimgurl: string;
subscribe: 0 | 1; // 0:未关注, 1:已关注
subscribe_time: string;
unsubscribe_time?: string;
unionid?: string;
remark: string;
groupid: number;
tagid_list: string;
subscribe_scene: string;
qr_scene?: string;
qr_scene_str?: string;
remark: string;
language: string;
qr_scene: string;
qr_scene_str: string;
create_time: string;
update_time: string;
}
export interface UserForm {
export interface WechatUserForm {
id?: number;
openid: string;
remark?: string;
remark: string;
groupid?: number;
}
@@ -37,109 +36,60 @@ export const sexOptions = [
{ label: '女', value: 2 },
];
export const sexMap = {
0: '未知',
1: '男',
2: '女',
};
export const subscribeOptions = [
{ label: '全部', value: '' },
{ label: '已关注', value: 1 },
{ label: '未关注', value: 0 },
];
export const subscribeMap = {
1: '已关注',
0: '未关注',
};
export const querySchema = [
{
fieldName: 'nickname',
label: '昵称',
component: 'Input',
},
{
fieldName: 'openid',
label: 'OpenID',
component: 'Input',
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
options: sexOptions,
placeholder: '请选择性别',
export const gridOptions: VxeGridProps<WechatUser> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'headimgurl', title: '头像', width: 80, formatter: ({ cellValue }) => {
return cellValue ? `<img src="${cellValue}" style="width: 40px; height: 40px; border-radius: 50%;" />` : '';
} },
{ field: 'nickname', title: '昵称', minWidth: 120 },
{ field: 'sex', title: '性别', width: 80, formatter: ({ cellValue }) => {
const option = sexOptions.find(item => item.value === cellValue);
return option?.label || '未知';
}},
{ field: 'city', title: '城市', width: 100 },
{ field: 'province', title: '省份', width: 100 },
{ field: 'country', title: '国家', width: 100 },
{ field: 'subscribe', title: '关注状态', width: 100, formatter: ({ cellValue }) => {
return cellValue === 1 ? '已关注' : '未关注';
}},
{ field: 'subscribe_time', title: '关注时间', width: 180 },
{ field: 'remark', title: '备注', minWidth: 150 },
{ field: 'groupid', title: '分组ID', width: 100 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 150,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: WechatUser) => {
// This will be handled in the component
},
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100, 200],
},
{
fieldName: 'subscribe',
label: '关注状态',
component: 'Select',
componentProps: {
options: subscribeOptions,
placeholder: '请选择关注状态',
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
{
fieldName: 'city',
label: '城市',
component: 'Input',
},
{
fieldName: 'province',
label: '省份',
component: 'Input',
},
{
fieldName: 'country',
label: '国家',
component: 'Input',
},
{
fieldName: 'subscribe_time',
label: '关注时间',
component: 'DatePicker',
componentProps: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{
field: 'headimgurl',
title: '头像',
width: 80,
slots: { default: 'avatar' },
align: 'center',
},
{ field: 'nickname', title: '昵称', minWidth: 150 },
{ field: 'sex', title: '性别', width: 80, slots: { default: 'sex' } },
{ field: 'city', title: '城市', width: 120 },
{ field: 'province', title: '省份', width: 120 },
{ field: 'country', title: '国家', width: 120 },
{ field: 'subscribe', title: '关注状态', width: 100, slots: { default: 'subscribe' } },
{ field: 'subscribe_time', title: '关注时间', width: 180 },
{ field: 'unsubscribe_time', title: '取消关注时间', width: 180 },
{ field: 'remark', title: '备注', minWidth: 150, showOverflow: true },
{ field: 'groupid', title: '分组ID', width: 100 },
{ field: 'tagid_list', title: '标签ID', minWidth: 150, showOverflow: true },
{ field: 'language', title: '语言', width: 100 },
{ field: 'subscribe_scene', title: '关注场景', minWidth: 150, showOverflow: true },
{
field: 'action',
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
},
];
};

View File

@@ -1,180 +1,119 @@
<template>
<div>
<div class="m-4">
<VbenVxeGrid
ref="gridRef"
:form-options="formOptions"
:grid-options="gridOptions"
:grid-events="gridEvents"
:query-schema="querySchema"
title="微信用户管理"
@toolbar-button-click="handleToolbarClick"
@cell-operation-click="handleCellOperationClick"
>
<template #toolbar-tools>
<template #toolbar-buttons>
<VbenButton type="primary" @click="handleSync">
<RefreshCw class="mr-2 h-4 w-4" />
<template #icon>
<SyncOutlined />
</template>
同步用户
</VbenButton>
</template>
<template #avatar="{ row }">
<VbenAvatar :src="row.headimgurl" :alt="row.nickname" size="small" />
</template>
<template #sex="{ row }">
<VbenTag :type="getSexColor(row.sex)">
{{ sexMap[row.sex] }}
</VbenTag>
</template>
<template #subscribe="{ row }">
<VbenTag :type="row.subscribe === 1 ? 'success' : 'error'">
{{ subscribeMap[row.subscribe] }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
type="text"
size="small"
@click="handleEdit(row)"
>
编辑
</VbenButton>
<VbenButton
type="text"
size="small"
@click="handleSendMessage(row)"
>
发消息
</VbenButton>
<VbenButton
type="text"
size="small"
@click="handleMoveGroup(row)"
>
移动分组
<VbenButton type="default" @click="handleExport">
<template #icon>
<ExportOutlined />
</template>
导出用户
</VbenButton>
</template>
</VbenVxeGrid>
<UserEditModal
v-model="modalVisible"
:data="currentData"
@reload="reloadTable"
/>
<SendMessageModal
v-model="messageModalVisible"
:user="currentData"
@reload="reloadTable"
/>
<MoveGroupModal
v-model="groupModalVisible"
:user="currentData"
@reload="reloadTable"
<UserForm
v-model="drawerVisible"
:user="currentUser"
@success="handleRefresh"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { RefreshCw } from '@vben/icons';
import { ref } from 'vue';
import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { VbenButton } from '@vben/common-ui';
import { SyncOutlined, ExportOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import UserForm from './modules/user-form.vue';
import { gridOptions, querySchema } from './data';
import { getWechatUserList, syncWechatUser, exportWechatUser } from '#/api/core/wechat';
import type { WechatUser } from './data';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { VbenAvatar, VbenButton, VbenMessage, VbenTag } from '@vben/common-ui';
const drawerVisible = ref(false);
const currentUser = ref<WechatUser | null>(null);
import { getWechatUserList, syncWechatUser } from '#/api/core/wechat';
import UserEditModal from './modules/user-edit.vue';
import SendMessageModal from './modules/send-message.vue';
import MoveGroupModal from './modules/move-group.vue';
import type { UserItem } from './data';
import { columns, querySchema, sexMap, subscribeMap } from './data';
const modalVisible = ref(false);
const messageModalVisible = ref(false);
const groupModalVisible = ref(false);
const currentData = ref<UserItem | null>(null);
const gridRef = ref();
const formOptions = computed(() => ({
schema: querySchema,
showCollapseButton: true,
fieldSize: 'medium',
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}));
const gridOptions = computed(() => ({
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
const [VbenVxeGrid, { reload }] = useVbenVxeGrid({
gridOptions,
querySchema,
queryList: async (params) => {
const { data } = await getWechatUserList(params);
return {
data: data.list,
total: data.total,
};
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
page: page.currentPage,
limit: page.pageSize,
...formValues,
};
return await getWechatUserList(params);
},
},
},
rowConfig: {
isHover: true,
},
columnConfig: {
minWidth: 100,
},
}));
});
const gridEvents = {
// 表格事件
const handleToolbarClick = (code: string) => {
switch (code) {
case 'sync':
handleSync();
break;
case 'export':
handleExport();
break;
default:
break;
}
};
function getSexColor(sex: number) {
const colorMap: Record<number, string> = {
0: 'default',
1: 'blue',
2: 'pink',
};
return colorMap[sex] || 'default';
}
function handleEdit(row: UserItem) {
currentData.value = row;
modalVisible.value = true;
}
function handleSendMessage(row: UserItem) {
currentData.value = row;
messageModalVisible.value = true;
}
function handleMoveGroup(row: UserItem) {
currentData.value = row;
groupModalVisible.value = true;
}
async function handleSync() {
try {
await syncWechatUser();
VbenMessage.success('用户同步成功');
reloadTable();
} catch (error) {
VbenMessage.error('用户同步失败');
const handleCellOperationClick = (code: string, row: WechatUser) => {
switch (code) {
case 'edit':
currentUser.value = row;
drawerVisible.value = true;
break;
default:
break;
}
}
};
function reloadTable() {
gridRef.value?.reload();
}
const handleSync = async () => {
try {
message.loading('正在同步微信用户...');
await syncWechatUser();
message.success('微信用户同步成功');
reload();
} catch (error) {
message.error('微信用户同步失败');
console.error('同步用户失败:', error);
}
};
onMounted(() => {
// 初始化
});
const handleExport = async () => {
try {
message.loading('正在导出用户数据...');
const { data } = await exportWechatUser();
// 创建下载链接
const link = document.createElement('a');
link.href = data.url;
link.download = '微信用户数据.xlsx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success('用户数据导出成功');
} catch (error) {
message.error('用户数据导出失败');
console.error('导出用户失败:', error);
}
};
const handleRefresh = () => {
reload();
};
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { WechatUserForm } from '../data';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useUserFormSchemas } from './formSchemas';
interface Props {
id: number;
userData: any;
}
interface Emits {
(e: 'submit', data: WechatUserForm): void;
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<WechatUserForm>({
remark: '',
groupid: 0,
});
const formSchemas = useUserFormSchemas();
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load user data
onMounted(async () => {
if (props.userData) {
model.value = {
remark: props.userData.remark || '',
groupid: props.userData.groupid || 0,
};
}
});
</script>

View File

@@ -0,0 +1,30 @@
import type { WechatUserForm } from '../data';
import { useVbenForm } from '@vben/common-ui';
import { $t } from '@vben/locale';
export const useUserFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Input',
fieldName: 'remark',
label: '备注',
componentProps: {
placeholder: '请输入备注',
maxlength: 100,
showCount: true,
},
},
{
component: 'InputNumber',
fieldName: 'groupid',
label: '分组ID',
componentProps: {
placeholder: '请输入分组ID',
min: 0,
},
},
]);
return formSchemas;
};

View File

@@ -0,0 +1,144 @@
<template>
<VbenDrawer
v-model:show="isShow"
:title="$t('channel.wechat.user.edit')"
:loading="loading"
width="600px"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<VbenForm
v-model:model="formModel"
v-model:schema="formSchema"
:label-width="100"
@submit="handleConfirm"
>
<template #avatar="{ model, field }">
<div class="flex items-center space-x-4">
<img
v-if="model.headimgurl"
:src="model.headimgurl"
class="w-16 h-16 rounded-full border-2 border-gray-200"
alt="用户头像"
/>
<div v-if="model.nickname" class="text-sm text-gray-600">
<div class="font-medium">{{ model.nickname }}</div>
<div>OpenID: {{ model.openid }}</div>
</div>
</div>
</template>
</VbenForm>
</VbenDrawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useVbenForm, useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { updateWechatUser } from '#/api/core/wechat';
import type { WechatUser, WechatUserForm } from '../data';
interface Props {
modelValue: boolean;
user?: WechatUser | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'success'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const [VbenForm, formModel, formSchema] = useVbenForm({
schema: [
{
fieldName: 'avatar',
label: '用户信息',
component: 'Slot',
slot: 'avatar',
formItemClass: 'mb-4',
},
{
fieldName: 'remark',
label: '备注',
component: 'InputTextArea',
componentProps: {
placeholder: '请输入备注信息',
rows: 3,
maxlength: 200,
showCount: true,
},
},
{
fieldName: 'groupid',
label: '分组',
component: 'InputNumber',
componentProps: {
placeholder: '请输入分组ID',
min: 0,
max: 100,
},
},
],
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const [VbenDrawer, isShow] = useVbenDrawer({
formModel,
formSchema,
});
// 监听props变化
watch(
() => props.modelValue,
(val) => {
isShow.value = val;
if (val && props.user) {
// 初始化表单数据
formModel.value = {
id: props.user.id,
remark: props.user.remark || '',
groupid: props.user.groupid || 0,
headimgurl: props.user.headimgurl,
nickname: props.user.nickname,
openid: props.user.openid,
};
}
},
{ immediate: true },
);
watch(isShow, (val) => {
emit('update:modelValue', val);
});
const handleConfirm = async () => {
try {
loading.value = true;
const data: WechatUserForm = {
id: formModel.value.id,
remark: formModel.value.remark,
groupid: formModel.value.groupid,
};
await updateWechatUser(data);
message.success('用户信息更新成功');
emit('success');
isShow.value = false;
} catch (error) {
message.error('用户信息更新失败');
console.error('更新用户失败:', error);
} finally {
loading.value = false;
}
};
const handleCancel = () => {
isShow.value = false;
};
</script>

View File

@@ -0,0 +1,117 @@
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface PaymentRecord {
id: number;
order_no: string;
trade_no: string;
user_id: number;
username: string;
amount: string;
pay_type: string;
pay_method: string;
status: 'pending' | 'paid' | 'failed' | 'refunded' | 'closed';
pay_time?: string;
notify_time?: string;
create_time: string;
update_time: string;
}
export interface PaymentForm {
id?: number;
order_no: string;
trade_no: string;
user_id: number;
amount: string;
pay_type: string;
pay_method: string;
status: string;
pay_time?: string;
notify_time?: string;
}
export const payTypeOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '余额支付', value: 'balance' },
{ label: '其他', value: 'other' },
];
export const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
{ label: '已关闭', value: 'closed' },
];
export const statusColorMap = {
pending: 'warning',
paid: 'success',
failed: 'error',
refunded: 'default',
closed: 'default',
};
export const gridOptions: VxeGridProps<PaymentRecord> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'trade_no', title: '交易号', minWidth: 180 },
{ field: 'username', title: '用户', width: 120 },
{ field: 'amount', title: '金额', width: 100, formatter: ({ cellValue }) => {
return `¥${cellValue}`;
}},
{ field: 'pay_type', title: '支付类型', width: 100, formatter: ({ cellValue }) => {
const option = payTypeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'pay_method', title: '支付方式', width: 120 },
{ field: 'status', title: '状态', width: 100, formatter: ({ cellValue }) => {
const colorMap = {
pending: 'warning',
paid: 'success',
failed: 'error',
refunded: 'default',
closed: 'default',
};
const color = colorMap[cellValue] || 'default';
const option = statusOptions.find(item => item.value === cellValue);
return `<span class="ant-tag ant-tag-${color}">${option?.label || cellValue}</span>`;
} },
{ field: 'pay_time', title: '支付时间', width: 180 },
{ field: 'create_time', title: '创建时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 150,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: PaymentRecord) => {
// This will be handled in the component
},
options: [
{ code: 'view', text: '查看详情', icon: 'ant-design:eye-outlined' },
],
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
};

View File

@@ -0,0 +1,217 @@
<template>
<Page auto-content-height>
<VbenVxeGrid
ref="gridRef"
:grid-options="gridOptions"
:query-form-schema="queryFormSchema"
@toolbar-button-click="handleToolbarButtonClick"
>
<template #status="{ row }">
<VbenTag :color="statusColorMap[row.status]">
{{ getStatusLabel(row.status) }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleView(row)"
>
详情
</VbenButton>
<VbenButton
v-if="row.status === 'paid'"
size="small"
type="warning"
variant="text"
@click="handleRefund(row)"
>
退款
</VbenButton>
<VbenPopconfirm
title="确定删除该记录吗?"
@confirm="handleDelete(row)"
>
<VbenButton
size="small"
type="danger"
variant="text"
>
{{ $t('common.delete') }}
</VbenButton>
</VbenPopconfirm>
</template>
</VbenVxeGrid>
<PaymentDetailModal
v-model:visible="detailModalVisible"
:payment-data="viewingPayment"
@cancel="handleDetailModalCancel"
/>
<RefundModal
v-model:visible="refundModalVisible"
:payment-data="refundingPayment"
@cancel="handleRefundModalCancel"
@submit="handleRefundSubmit"
/>
</Page>
</template>
<script lang="ts" setup>
import type { PaymentRecord } from './data';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid, VbenButton, VbenPopconfirm, VbenTag, VbenVxeGrid } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { getPaymentListApi, deletePaymentApi, refundPaymentApi } from '#/api/core/finance';
import { SvgIcon } from '#/components/icon';
import PaymentDetailModal from './modules/detail.vue';
import RefundModal from './modules/refund.vue';
import { gridOptions, payTypeOptions, statusOptions, statusColorMap } from './data';
const gridRef = ref();
const detailModalVisible = ref(false);
const refundModalVisible = ref(false);
const viewingPayment = ref<any>(null);
const refundingPayment = ref<any>(null);
const queryFormSchema = computed(() => [
{
component: 'Input',
fieldName: 'order_no',
label: '订单号',
},
{
component: 'Input',
fieldName: 'trade_no',
label: '交易号',
},
{
component: 'Input',
fieldName: 'username',
label: '用户',
},
{
component: 'Select',
fieldName: 'pay_type',
label: '支付类型',
componentProps: {
options: [
{ label: '全部', value: '' },
...payTypeOptions,
],
placeholder: '请选择支付类型',
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
options: [
{ label: '全部', value: '' },
...statusOptions,
],
placeholder: '请选择状态',
},
},
{
component: 'DateRange',
fieldName: 'create_time',
label: '创建时间',
componentProps: {
placeholder: ['开始时间', '结束时间'],
},
},
]);
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
queryFormSchema,
});
function handleToolbarButtonClick(event: string) {
switch (event) {
case 'add':
handleAdd();
break;
case 'refresh':
handleRefresh();
break;
case 'export':
handleExport();
break;
default:
break;
}
}
function handleAdd() {
// Payment records are generated automatically, cannot manually add
$message.info('支付记录自动生成,无法手动添加');
}
function handleView(row: PaymentRecord) {
viewingPayment.value = row;
detailModalVisible.value = true;
}
async function handleRefund(row: PaymentRecord) {
refundingPayment.value = row;
refundModalVisible.value = true;
}
async function handleDelete(row: PaymentRecord) {
try {
await deletePaymentApi(row.id);
await handleRefresh();
$message.success('删除成功');
} catch (error) {
$message.error('删除失败');
}
}
function handleDetailModalCancel() {
detailModalVisible.value = false;
viewingPayment.value = null;
}
function handleRefundModalCancel() {
refundModalVisible.value = false;
refundingPayment.value = null;
}
async function handleRefundSubmit(refundData: any) {
try {
await refundPaymentApi(refundingPayment.value.id, refundData);
refundModalVisible.value = false;
await handleRefresh();
$message.success('退款成功');
} catch (error) {
$message.error('退款失败');
}
}
function getStatusLabel(status: string): string {
const option = statusOptions.find(item => item.value === status);
return option?.label || status;
}
async function handleRefresh() {
await gridApi.query();
}
function handleExport() {
gridApi.exportData({
filename: '支付记录列表',
type: 'csv',
});
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium mb-4">支付详情</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">订单信息</h4>
<div class="space-y-2 text-sm">
<div><strong>订单号:</strong> {{ paymentData.order_no }}</div>
<div><strong>交易号:</strong> {{ paymentData.trade_no }}</div>
<div><strong>用户:</strong> {{ paymentData.username }}</div>
<div><strong>金额:</strong> <span class="text-red-600">¥{{ paymentData.amount }}</span></div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">支付信息</h4>
<div class="space-y-2 text-sm">
<div><strong>支付类型:</strong> {{ getPayTypeLabel(paymentData.pay_type) }}</div>
<div><strong>支付方式:</strong> {{ paymentData.pay_method }}</div>
<div><strong>状态:</strong> <VbenTag :color="statusColorMap[paymentData.status]">{{ getStatusLabel(paymentData.status) }}</VbenTag></div>
<div><strong>支付时间:</strong> {{ paymentData.pay_time || '-' }}</div>
</div>
</div>
</div>
</div>
<div class="mb-6">
<h4 class="font-medium mb-2">时间记录</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="grid grid-cols-2 gap-4 text-sm">
<div><strong>创建时间:</strong> {{ paymentData.create_time }}</div>
<div><strong>更新时间:</strong> {{ paymentData.update_time }}</div>
<div><strong>通知时间:</strong> {{ paymentData.notify_time || '-' }}</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-2">
<VbenButton @click="handleClose" variant="outline">
{{ $t('common.close') }}
</VbenButton>
<VbenButton
v-if="paymentData.status === 'paid'"
type="warning"
@click="handleRefund"
>
退款
</VbenButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { VbenButton, VbenTag } from '@vben/common-ui';
import { $t } from '@vben/locale';
interface Props {
paymentData: any;
}
interface Emits {
(e: 'cancel'): void;
(e: 'refund'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const payTypeOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '余额支付', value: 'balance' },
{ label: '其他', value: 'other' },
];
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
{ label: '已关闭', value: 'closed' },
];
const statusColorMap = {
pending: 'warning',
paid: 'success',
failed: 'error',
refunded: 'default',
closed: 'default',
};
function getPayTypeLabel(type: string): string {
const option = payTypeOptions.find(item => item.value === type);
return option?.label || type;
}
function getStatusLabel(status: string): string {
const option = statusOptions.find(item => item.value === status);
return option?.label || status;
}
function handleClose() {
emit('cancel');
}
function handleRefund() {
emit('refund');
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div class="p-6">
<h3 class="text-lg font-medium mb-4">退款操作</h3>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
确认退款
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import { VbenButton, VbenForm, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locale';
interface Props {
paymentData: any;
}
interface Emits {
(e: 'cancel'): void;
(e: 'submit', data: any): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const [Modal] = useVbenModal();
const model = ref({
refund_amount: props.paymentData?.amount || '',
refund_reason: '',
notify_url: '',
});
const formSchemas = computed(() => [
{
component: 'InputNumber',
fieldName: 'refund_amount',
label: '退款金额',
rules: 'required|min:0.01|max:' + props.paymentData?.amount,
componentProps: {
placeholder: '请输入退款金额',
min: 0.01,
max: parseFloat(props.paymentData?.amount || '0'),
step: 0.01,
precision: 2,
},
},
{
component: 'Textarea',
fieldName: 'refund_reason',
label: '退款原因',
rules: 'required',
componentProps: {
placeholder: '请输入退款原因',
rows: 3,
maxlength: 200,
showCount: true,
},
},
{
component: 'Input',
fieldName: 'notify_url',
label: '通知地址',
componentProps: {
placeholder: '请输入退款通知地址(可选)',
},
},
]);
async function handleSubmit() {
try {
await Modal?.formApi.validate();
const formValues = Modal?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
</script>

View File

@@ -0,0 +1,103 @@
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface AdminLog {
id: number;
admin_id: number;
admin_name: string;
module: string;
controller: string;
action: string;
method: string;
url: string;
params: string;
ip: string;
user_agent: string;
result: 'success' | 'failed';
message?: string;
create_time: string;
}
export interface LogForm {
id?: number;
admin_id: number;
admin_name: string;
module: string;
controller: string;
action: string;
method: string;
url: string;
params: string;
ip: string;
user_agent: string;
result: string;
message?: string;
}
export const resultOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
];
export const methodOptions = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' },
];
export const resultColorMap = {
success: 'success',
failed: 'error',
};
export const gridOptions: VxeGridProps<AdminLog> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'admin_name', title: '管理员', width: 120 },
{ field: 'module', title: '模块', width: 100 },
{ field: 'controller', title: '控制器', width: 120 },
{ field: 'action', title: '操作', width: 100 },
{ field: 'method', title: '方法', width: 80 },
{ field: 'url', title: 'URL', minWidth: 200, showOverflow: true },
{ field: 'ip', title: 'IP地址', width: 120 },
{ field: 'result', title: '结果', width: 80, formatter: ({ cellValue }) => {
const colorMap = { success: 'success', failed: 'error' };
const color = colorMap[cellValue] || 'default';
return `<span class="ant-tag ant-tag-${color}">${cellValue === 'success' ? '成功' : '失败'}</span>`;
} },
{ field: 'create_time', title: '操作时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 100,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: AdminLog) => {
// This will be handled in the component
},
options: [
{ code: 'view', text: '查看详情', icon: 'ant-design:eye-outlined' },
],
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
};

View File

@@ -0,0 +1,187 @@
<template>
<Page auto-content-height>
<VbenVxeGrid
ref="gridRef"
:grid-options="gridOptions"
:query-form-schema="queryFormSchema"
@toolbar-button-click="handleToolbarButtonClick"
>
<template #toolbar-tools>
<VbenButton type="danger" @click="handleClearLogs">
<SvgIcon icon="mdi:delete-sweep" class="mr-1" />
清空日志
</VbenButton>
</template>
<template #result="{ row }">
<VbenTag :color="resultColorMap[row.result]">
{{ getResultLabel(row.result) }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleView(row)"
>
详情
</VbenButton>
</template>
</VbenVxeGrid>
<LogDetailModal
v-model:visible="detailModalVisible"
:log-data="viewingLog"
@cancel="handleDetailModalCancel"
/>
</Page>
</template>
<script lang="ts" setup>
import type { AdminLog } from './data';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid, VbenButton, VbenTag, VbenVxeGrid } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { getAdminLogListApi, deleteAdminLogApi, clearAdminLogApi } from '#/api/core/log';
import { SvgIcon } from '#/components/icon';
import LogDetailModal from './modules/detail.vue';
import { gridOptions, resultOptions, resultColorMap } from './data';
const gridRef = ref();
const detailModalVisible = ref(false);
const viewingLog = ref<any>(null);
const queryFormSchema = computed(() => [
{
component: 'Input',
fieldName: 'admin_name',
label: '管理员',
},
{
component: 'Input',
fieldName: 'module',
label: '模块',
},
{
component: 'Input',
fieldName: 'controller',
label: '控制器',
},
{
component: 'Input',
fieldName: 'action',
label: '操作',
},
{
component: 'Select',
fieldName: 'method',
label: '方法',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' },
],
placeholder: '请选择请求方法',
},
},
{
component: 'Input',
fieldName: 'ip',
label: 'IP地址',
},
{
component: 'Select',
fieldName: 'result',
label: '结果',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
],
placeholder: '请选择操作结果',
},
},
{
component: 'DateRange',
fieldName: 'create_time',
label: '操作时间',
componentProps: {
placeholder: ['开始时间', '结束时间'],
},
},
]);
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
queryFormSchema,
});
function handleToolbarButtonClick(event: string) {
switch (event) {
case 'add':
handleAdd();
break;
case 'refresh':
handleRefresh();
break;
case 'export':
handleExport();
break;
default:
break;
}
}
function handleAdd() {
// Admin logs are generated automatically, cannot manually add
$message.info('管理员日志自动生成,无法手动添加');
}
function handleView(row: AdminLog) {
viewingLog.value = row;
detailModalVisible.value = true;
}
async function handleClearLogs() {
try {
await clearAdminLogApi();
await handleRefresh();
$message.success('日志清空成功');
} catch (error) {
$message.error('日志清空失败');
}
}
function handleDetailModalCancel() {
detailModalVisible.value = false;
viewingLog.value = null;
}
function getResultLabel(result: string): string {
const option = resultOptions.find(item => item.value === result);
return option?.label || result;
}
async function handleRefresh() {
await gridApi.query();
}
function handleExport() {
gridApi.exportData({
filename: '管理员日志列表',
type: 'csv',
});
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium mb-4">日志详情</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">管理员信息</h4>
<div class="space-y-2 text-sm">
<div><strong>管理员ID:</strong> {{ logData.admin_id }}</div>
<div><strong>管理员名称:</strong> {{ logData.admin_name }}</div>
<div><strong>IP地址:</strong> {{ logData.ip }}</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">操作信息</h4>
<div class="space-y-2 text-sm">
<div><strong>模块:</strong> {{ logData.module }}</div>
<div><strong>控制器:</strong> {{ logData.controller }}</div>
<div><strong>操作:</strong> {{ logData.action }}</div>
<div><strong>方法:</strong> {{ logData.method }}</div>
<div><strong>结果:</strong> <VbenTag :color="resultColorMap[logData.result]">{{ getResultLabel(logData.result) }}</VbenTag></div>
</div>
</div>
</div>
</div>
<div class="mb-6">
<h4 class="font-medium mb-2">请求信息</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="space-y-2 text-sm">
<div><strong>URL:</strong> {{ logData.url }}</div>
<div><strong>UserAgent:</strong> {{ logData.user_agent }}</div>
</div>
</div>
</div>
<div class="mb-6">
<h4 class="font-medium mb-2">参数信息</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="text-sm whitespace-pre-wrap">{{ formatParams(logData.params) }}</pre>
</div>
</div>
<div v-if="logData.message" class="mb-6">
<h4 class="font-medium mb-2">消息</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<pre class="text-sm whitespace-pre-wrap">{{ logData.message }}</pre>
</div>
</div>
<div class="mb-6">
<h4 class="font-medium mb-2">时间记录</h4>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm">
<div><strong>操作时间:</strong> {{ logData.create_time }}</div>
</div>
</div>
</div>
<div class="flex justify-end">
<VbenButton @click="handleClose" variant="outline">
{{ $t('common.close') }}
</VbenButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { VbenButton, VbenTag } from '@vben/common-ui';
import { $t } from '@vben/locale';
interface Props {
logData: any;
}
interface Emits {
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const resultOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
];
const resultColorMap = {
success: 'success',
failed: 'error',
};
function getResultLabel(result: string): string {
const option = resultOptions.find(item => item.value === result);
return option?.label || result;
}
function formatParams(params: string): string {
try {
const parsed = JSON.parse(params);
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
}
function handleClose() {
emit('cancel');
}
</script>

View File

@@ -87,17 +87,23 @@ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'payment_name', title: '支付名称', minWidth: 150 },
{ field: 'payment_type', title: '支付类型', width: 120, slots: { default: 'paymentType' } },
{ field: 'payment_type', title: '支付类型', width: 120, formatter: ({ cellValue }) => {
return paymentTypeMap[cellValue] || cellValue;
} },
{ field: 'payment_code', title: '支付编码', width: 150 },
{
field: 'icon',
title: '图标',
width: 100,
slots: { default: 'icon' },
align: 'center',
formatter: ({ cellValue }) => {
return cellValue ? `<i class="${cellValue}" style="font-size: 24px;"></i>` : '';
},
},
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
} },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
@@ -105,6 +111,18 @@ export const columns: VxeGridProps['columns'] = [
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: PaymentItem) => {
// This will be handled in the component
},
options: [
{ code: 'edit', text: '编辑', icon: 'ant-design:edit-outlined' },
{ code: 'config', text: '配置', icon: 'ant-design:setting-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
];

View File

@@ -91,11 +91,15 @@ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'sms_name', title: '短信名称', minWidth: 150 },
{ field: 'sms_type', title: '短信类型', width: 120, slots: { default: 'smsType' } },
{ field: 'sms_type', title: '短信类型', width: 120, formatter: ({ cellValue }) => {
return smsTypeMap[cellValue] || cellValue;
} },
{ field: 'sms_code', title: '短信编码', width: 150 },
{ field: 'sign_name', title: '签名名称', width: 150 },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
} },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
@@ -103,6 +107,18 @@ export const columns: VxeGridProps['columns'] = [
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: SmsItem) => {
// This will be handled in the component
},
options: [
{ code: 'edit', text: '编辑', icon: 'ant-design:edit-outlined' },
{ code: 'test', text: '测试', icon: 'ant-design:notification-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
];

View File

@@ -105,11 +105,17 @@ export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'storage_name', title: '存储名称', minWidth: 150 },
{ field: 'storage_type', title: '存储类型', width: 120, slots: { default: 'storageType' } },
{ field: 'storage_type', title: '存储类型', width: 120, formatter: ({ cellValue }) => {
return storageTypeMap[cellValue] || cellValue;
} },
{ field: 'storage_code', title: '存储编码', width: 150 },
{ field: 'is_default', title: '默认存储', width: 100, slots: { default: 'isDefault' } },
{ field: 'is_default', title: '默认存储', width: 100, formatter: ({ cellValue }) => {
return cellValue === 1 ? '是' : '否';
} },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
} },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
@@ -117,6 +123,18 @@ export const columns: VxeGridProps['columns'] = [
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: StorageItem) => {
// This will be handled in the component
},
options: [
{ code: 'edit', text: '编辑', icon: 'ant-design:edit-outlined' },
{ code: 'setDefault', text: '设为默认', icon: 'ant-design:star-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
];

View File

@@ -91,12 +91,19 @@ export const querySchema = [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'app_module', title: '模块', width: 120, slots: { default: 'module' } },
{ field: 'app_module', title: '模块', width: 120, formatter: ({ cellValue }) => {
const option = moduleOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
} },
{ field: 'config_key', title: '配置键', width: 200 },
{ field: 'config_value', title: '配置值', minWidth: 200, showOverflow: true },
{ field: 'config_desc', title: '配置描述', minWidth: 200, showOverflow: true },
{ field: 'is_system', title: '系统配置', width: 100, slots: { default: 'isSystem' } },
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
{ field: 'is_system', title: '系统配置', width: 100, formatter: ({ cellValue }) => {
return cellValue === 1 ? '是' : '否';
} },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
} },
{ field: 'create_time', title: '创建时间', width: 180 },
{ field: 'update_time', title: '更新时间', width: 180 },
{
@@ -104,6 +111,17 @@ export const columns: VxeGridProps['columns'] = [
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'action' },
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: ConfigItem) => {
// This will be handled in the component
},
options: [
{ code: 'edit', text: '编辑', icon: 'ant-design:edit-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
];

View File

@@ -0,0 +1,116 @@
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface SystemConfig {
id: number;
site_id: number;
name: string;
title: string;
value: string;
type: 'text' | 'textarea' | 'number' | 'date' | 'datetime' | 'select' | 'radio' | 'checkbox' | 'image' | 'file' | 'color' | 'array' | 'json';
options?: string;
tips?: string;
group: string;
sort: number;
status: 0 | 1;
create_time: string;
update_time: string;
}
export interface ConfigForm {
id?: number;
site_id: number;
name: string;
title: string;
value: string;
type: string;
options?: string;
tips?: string;
group: string;
sort: number;
status: 0 | 1;
}
export const typeOptions = [
{ label: '文本框', value: 'text' },
{ label: '文本域', value: 'textarea' },
{ label: '数字', value: 'number' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '下拉框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '复选框', value: 'checkbox' },
{ label: '图片上传', value: 'image' },
{ label: '文件上传', value: 'file' },
{ label: '颜色选择', value: 'color' },
{ label: '数组', value: 'array' },
{ label: 'JSON', value: 'json' },
];
export const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
export const groupOptions = [
{ label: '站点配置', value: 'site' },
{ label: '系统配置', value: 'system' },
{ label: '上传配置', value: 'upload' },
{ label: '邮件配置', value: 'email' },
{ label: '短信配置', value: 'sms' },
{ label: '支付配置', value: 'payment' },
{ label: '其他配置', value: 'other' },
];
export const gridOptions: VxeGridProps<SystemConfig> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'name', title: '配置名称', minWidth: 150 },
{ field: 'title', title: '配置标题', minWidth: 150 },
{ field: 'type', title: '类型', width: 100, formatter: ({ cellValue }) => {
const option = typeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'group', title: '分组', width: 100, formatter: ({ cellValue }) => {
const option = groupOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'value', title: '配置值', minWidth: 200, showOverflow: true },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status', title: '状态', width: 80, formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
}},
{
field: 'action',
fixed: 'right',
title: '操作',
width: 150,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: SystemConfig) => {
// This will be handled in the component
},
options: [
{ code: 'edit', text: '编辑', icon: 'ant-design:edit-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
};

View File

@@ -0,0 +1,204 @@
<template>
<Page auto-content-height>
<VbenVxeGrid
ref="gridRef"
:grid-options="gridOptions"
:query-form-schema="queryFormSchema"
@toolbar-button-click="handleToolbarButtonClick"
>
<template #toolbar-tools>
<VbenButton type="primary" @click="handleRefreshCache">
<SvgIcon icon="mdi:refresh" class="mr-1" />
刷新缓存
</VbenButton>
</template>
<template #action="{ row }">
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleEdit(row)"
>
{{ $t('common.edit') }}
</VbenButton>
<VbenPopconfirm
title="确定删除该配置吗?"
@confirm="handleDelete(row)"
>
<VbenButton
size="small"
type="danger"
variant="text"
>
{{ $t('common.delete') }}
</VbenButton>
</VbenPopconfirm>
</template>
</VbenVxeGrid>
<ConfigFormModal
v-model:visible="modalVisible"
:id="editingId"
@cancel="handleModalCancel"
@submit="handleModalSubmit"
/>
</Page>
</template>
<script lang="ts" setup>
import type { SystemConfig, ConfigForm } from './data';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid, VbenButton, VbenPopconfirm, VbenVxeGrid } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { getSystemConfigListApi, createSystemConfigApi, updateSystemConfigApi, deleteSystemConfigApi, refreshSystemConfigCacheApi } from '#/api/core/system';
import { SvgIcon } from '#/components/icon';
import ConfigFormModal from './modules/form.vue';
import { gridOptions } from './data';
const gridRef = ref();
const modalVisible = ref(false);
const editingId = ref<number | undefined>();
const queryFormSchema = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '配置名称',
},
{
component: 'Input',
fieldName: 'title',
label: '配置标题',
},
{
component: 'Select',
fieldName: 'group',
label: '配置分组',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '站点配置', value: 'site' },
{ label: '系统配置', value: 'system' },
{ label: '上传配置', value: 'upload' },
{ label: '邮件配置', value: 'email' },
{ label: '短信配置', value: 'sms' },
{ label: '支付配置', value: 'payment' },
{ label: '其他配置', value: 'other' },
],
placeholder: '请选择配置分组',
},
},
{
component: 'Select',
fieldName: 'type',
label: '配置类型',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '文本框', value: 'text' },
{ label: '文本域', value: 'textarea' },
{ label: '数字', value: 'number' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '下拉框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '复选框', value: 'checkbox' },
{ label: '图片上传', value: 'image' },
{ label: '文件上传', value: 'file' },
{ label: '颜色选择', value: 'color' },
{ label: '数组', value: 'array' },
{ label: 'JSON', value: 'json' },
],
placeholder: '请选择配置类型',
},
},
]);
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
queryFormSchema,
});
function handleToolbarButtonClick(event: string) {
switch (event) {
case 'add':
handleAdd();
break;
case 'refresh':
handleRefresh();
break;
case 'export':
handleExport();
break;
default:
break;
}
}
function handleAdd() {
editingId.value = undefined;
modalVisible.value = true;
}
function handleEdit(row: SystemConfig) {
editingId.value = row.id;
modalVisible.value = true;
}
async function handleDelete(row: SystemConfig) {
try {
await deleteSystemConfigApi(row.id);
await handleRefresh();
$message.success('删除成功');
} catch (error) {
$message.error('删除失败');
}
}
async function handleRefreshCache() {
try {
await refreshSystemConfigCacheApi();
$message.success('缓存刷新成功');
} catch (error) {
$message.error('缓存刷新失败');
}
}
function handleModalCancel() {
modalVisible.value = false;
editingId.value = undefined;
}
async function handleModalSubmit(data: ConfigForm) {
try {
if (editingId.value) {
await updateSystemConfigApi(editingId.value, data);
$message.success('更新成功');
} else {
await createSystemConfigApi(data);
$message.success('创建成功');
}
modalVisible.value = false;
await handleRefresh();
} catch (error) {
$message.error(editingId.value ? '更新失败' : '创建失败');
}
}
async function handleRefresh() {
await gridApi.query();
}
function handleExport() {
gridApi.exportData({
filename: '系统配置列表',
type: 'csv',
});
}
</script>

View File

@@ -0,0 +1,87 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { ConfigForm } from '../data';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useConfigFormSchemas } from './formSchemas';
interface Props {
id?: number;
}
interface Emits {
(e: 'submit', data: ConfigForm): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
});
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<ConfigForm>({
site_id: 0,
name: '',
title: '',
type: 'text',
value: '',
group: 'other',
sort: 0,
status: 1,
});
const formSchemas = useConfigFormSchemas();
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load config data if editing
onMounted(async () => {
if (props.id) {
try {
// Load config data
const configData = await getSystemConfigDetailApi(props.id);
model.value = { ...configData };
} catch (error) {
console.error('Failed to load config data:', error);
}
}
});
</script>

View File

@@ -0,0 +1,92 @@
import type { ConfigForm } from '../data';
import { useVbenForm } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { typeOptions, statusOptions, groupOptions } from '../data';
export const useConfigFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '配置名称',
rules: 'required|pattern:^[a-zA-Z_][a-zA-Z0-9_]*$',
componentProps: {
placeholder: '请输入配置名称(英文)',
},
},
{
component: 'Input',
fieldName: 'title',
label: '配置标题',
rules: 'required',
componentProps: {
placeholder: '请输入配置标题',
},
},
{
component: 'Select',
fieldName: 'type',
label: '配置类型',
rules: 'required',
componentProps: {
options: typeOptions,
placeholder: '请选择配置类型',
},
},
{
component: 'Select',
fieldName: 'group',
label: '配置分组',
rules: 'required',
componentProps: {
options: groupOptions,
placeholder: '请选择配置分组',
},
},
{
component: 'Textarea',
fieldName: 'options',
label: '配置选项',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return ['select', 'radio', 'checkbox'].includes(form.type);
}),
componentProps: {
placeholder: '请输入配置选项格式key:value每行一个',
rows: 4,
},
},
{
component: 'Textarea',
fieldName: 'tips',
label: '配置说明',
componentProps: {
placeholder: '请输入配置说明',
rows: 3,
},
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
defaultValue: 0,
componentProps: {
min: 0,
max: 999,
},
},
{
component: 'RadioGroup',
fieldName: 'status',
label: '状态',
defaultValue: 1,
componentProps: {
options: statusOptions,
},
},
]);
return formSchemas;
};

View File

@@ -195,77 +195,47 @@ export function useColumns(
title: $t('site.list.status'),
field: 'status',
width: 100,
slots: {
default: ({ row }) => (
<Tag
color={getStatusColor(row.status)}
class="cursor-pointer"
onClick={() => onStatusChange(row.status, row.site_id)}
>
{row.status_name}
</Tag>
),
formatter: ({ row }) => {
const colorMap: Record<number, string> = {
0: 'red',
1: 'green',
2: 'orange',
3: 'red',
};
const color = colorMap[row.status] || 'default';
return `<span class="ant-tag ant-tag-${color} cursor-pointer" onclick="window.handleStatusChange(${row.status}, ${row.site_id})">${row.status_name}</span>`;
},
},
{
title: $t('common.action'),
field: 'action',
width: 280,
slots: {
default: ({ row }) => (
<Space>
<Button
type="link"
size="small"
onClick={() => handleToSiteLink(row.site_id)}
>
<Icon icon="ant-design:login-outlined" />
{$t('site.list.toSite')}
</Button>
{(row.status === 1 || row.status === 3) && (
<Button
type="link"
size="small"
onClick={() => onOpenClose(row.status, row.site_id)}
>
{row.status === 1 ? $t('site.list.close') : $t('site.list.open')}
</Button>
)}
<Button
type="link"
size="small"
onClick={() => onActionClick('info', row)}
>
<Icon icon="ant-design:info-circle-outlined" />
{$t('site.list.info')}
</Button>
<Button
type="link"
size="small"
onClick={() => onActionClick('init', row)}
>
<Icon icon="ant-design:reload-outlined" />
{$t('site.list.initSite')}
</Button>
<Button
type="link"
size="small"
onClick={() => onActionClick('edit', row)}
>
<Icon icon="ant-design:edit-outlined" />
{$t('common.edit')}
</Button>
<Popconfirm
title={$t('site.list.deleteConfirm')}
onConfirm={() => onActionClick('delete', row)}
>
<Button type="link" size="small" danger>
<Icon icon="ant-design:delete-outlined" />
{$t('common.delete')}
</Button>
</Popconfirm>
</Space>
),
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: any) => {
if (code === 'toSite') {
window.open(`/site/${row.site_id}`, '_blank');
} else if (code === 'toggleStatus') {
onOpenClose(row.status, row.site_id);
} else if (code === 'info') {
onActionClick('info', row);
} else if (code === 'init') {
onActionClick('init', row);
} else if (code === 'edit') {
onActionClick('edit', row);
} else if (code === 'delete') {
onActionClick('delete', row);
}
},
options: [
{ code: 'toSite', text: $t('site.list.toSite'), icon: 'ant-design:login-outlined' },
{ code: 'info', text: $t('site.list.info'), icon: 'ant-design:info-circle-outlined' },
{ code: 'init', text: $t('site.list.initSite'), icon: 'ant-design:reload-outlined' },
{ code: 'edit', text: $t('common.edit'), icon: 'ant-design:edit-outlined' },
{ code: 'delete', text: $t('common.delete'), icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
];

View File

@@ -29,10 +29,12 @@ export function useColumns(
align: 'left',
field: 'meta.title',
fixed: 'left',
slots: { default: 'title' },
title: $t('system.menu.menuTitle'),
treeNode: true,
width: 250,
formatter: ({ row }) => {
return row.meta?.title || '';
},
},
{
align: 'center',

View File

@@ -0,0 +1,99 @@
import type { VxeGridProps } from '@vben/plugins/vxe-table';
export interface BackupInfo {
id: number;
name: string;
type: 'database' | 'file' | 'full';
size: string;
path: string;
status: 'success' | 'failed' | 'running';
start_time: string;
end_time?: string;
create_time: string;
}
export interface BackupForm {
id?: number;
name: string;
type: string;
tables?: string[];
exclude_tables?: string[];
compress: 0 | 1;
}
export const typeOptions = [
{ label: '数据库备份', value: 'database' },
{ label: '文件备份', value: 'file' },
{ label: '完整备份', value: 'full' },
];
export const statusOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '进行中', value: 'running' },
];
export const statusColorMap = {
success: 'success',
failed: 'error',
running: 'processing',
};
export const gridOptions: VxeGridProps<BackupInfo> = {
columns: [
{ type: 'checkbox', width: 50 },
{ field: 'name', title: '备份名称', minWidth: 150 },
{ field: 'type', title: '备份类型', width: 120, formatter: ({ cellValue }) => {
const option = typeOptions.find(item => item.value === cellValue);
return option?.label || cellValue;
}},
{ field: 'size', title: '文件大小', width: 100 },
{ field: 'status', title: '状态', width: 100, formatter: ({ cellValue }) => {
const colorMap = {
success: 'success',
failed: 'error',
running: 'processing',
};
const color = colorMap[cellValue] || 'default';
const option = statusOptions.find(item => item.value === cellValue);
return `<span class="ant-tag ant-tag-${color}">${option?.label || cellValue}</span>`;
} },
{ field: 'start_time', title: '开始时间', width: 180 },
{ field: 'end_time', title: '结束时间', width: 180 },
{ field: 'create_time', title: '创建时间', width: 180 },
{
field: 'action',
fixed: 'right',
title: '操作',
width: 200,
cellRender: {
name: 'CellOperation',
attrs: {
onClick: (code: string, row: BackupInfo) => {
// This will be handled in the component
},
options: [
{ code: 'download', text: '下载', icon: 'ant-design:download-outlined' },
{ code: 'restore', text: '还原', icon: 'ant-design:reload-outlined' },
{ code: 'delete', text: '删除', icon: 'ant-design:delete-outlined', danger: true },
],
},
},
},
],
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: true,
pageSize: 20,
pageSizes: [10, 20, 50, 100],
},
toolbarConfig: {
custom: true,
export: true,
// import: true,
print: true,
refresh: true,
zoom: true,
},
};

View File

@@ -0,0 +1,219 @@
<template>
<Page auto-content-height>
<VbenVxeGrid
ref="gridRef"
:grid-options="gridOptions"
:query-form-schema="queryFormSchema"
@toolbar-button-click="handleToolbarButtonClick"
>
<template #toolbar-tools>
<VbenButton type="primary" @click="handleCreateBackup">
<SvgIcon icon="mdi:backup" class="mr-1" />
创建备份
</VbenButton>
</template>
<template #status="{ row }">
<VbenTag :color="statusColorMap[row.status]">
{{ getStatusLabel(row.status) }}
</VbenTag>
</template>
<template #action="{ row }">
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleDownload(row)"
>
下载
</VbenButton>
<VbenButton
size="small"
type="primary"
variant="text"
@click="handleRestore(row)"
>
还原
</VbenButton>
<VbenPopconfirm
title="确定删除该备份吗?"
@confirm="handleDelete(row)"
>
<VbenButton
size="small"
type="danger"
variant="text"
>
{{ $t('common.delete') }}
</VbenButton>
</VbenPopconfirm>
</template>
</VbenVxeGrid>
<BackupFormModal
v-model:visible="modalVisible"
:id="editingId"
@cancel="handleModalCancel"
@submit="handleModalSubmit"
/>
</Page>
</template>
<script lang="ts" setup>
import type { BackupInfo, BackupForm } from './data';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid, VbenButton, VbenPopconfirm, VbenTag, VbenVxeGrid } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { getBackupListApi, createBackupApi, deleteBackupApi, downloadBackupApi, restoreBackupApi } from '#/api/core/tools';
import { SvgIcon } from '#/components/icon';
import BackupFormModal from './modules/form.vue';
import { gridOptions, statusOptions, statusColorMap } from './data';
const gridRef = ref();
const modalVisible = ref(false);
const editingId = ref<number | undefined>();
const queryFormSchema = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '备份名称',
},
{
component: 'Select',
fieldName: 'type',
label: '备份类型',
componentProps: {
options: [
{ label: '全部', value: '' },
{ label: '数据库备份', value: 'database' },
{ label: '文件备份', value: 'file' },
{ label: '完整备份', value: 'full' },
],
placeholder: '请选择备份类型',
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
options: [
{ label: '全部', value: '' },
...statusOptions,
],
placeholder: '请选择状态',
},
},
{
component: 'DateRange',
fieldName: 'create_time',
label: '创建时间',
componentProps: {
placeholder: ['开始时间', '结束时间'],
},
},
]);
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
queryFormSchema,
});
function handleToolbarButtonClick(event: string) {
switch (event) {
case 'add':
handleAdd();
break;
case 'refresh':
handleRefresh();
break;
case 'export':
handleExport();
break;
default:
break;
}
}
function handleAdd() {
editingId.value = undefined;
modalVisible.value = true;
}
function getStatusLabel(status: string): string {
const option = statusOptions.find(item => item.value === status);
return option?.label || status;
}
async function handleDownload(row: BackupInfo) {
try {
await downloadBackupApi(row.id);
$message.success('下载开始');
} catch (error) {
$message.error('下载失败');
}
}
async function handleRestore(row: BackupInfo) {
try {
await restoreBackupApi(row.id);
await handleRefresh();
$message.success('还原成功');
} catch (error) {
$message.error('还原失败');
}
}
async function handleDelete(row: BackupInfo) {
try {
await deleteBackupApi(row.id);
await handleRefresh();
$message.success('删除成功');
} catch (error) {
$message.error('删除失败');
}
}
function handleModalCancel() {
modalVisible.value = false;
editingId.value = undefined;
}
async function handleModalSubmit(data: BackupForm) {
try {
if (editingId.value) {
// Backup editing is not supported, only creation
$message.info('备份不支持编辑');
} else {
await createBackupApi(data);
$message.success('备份创建成功');
}
modalVisible.value = false;
await handleRefresh();
} catch (error) {
$message.error(editingId.value ? '操作失败' : '创建失败');
}
}
async function handleRefresh() {
await gridApi.query();
}
function handleExport() {
gridApi.exportData({
filename: '备份列表',
type: 'csv',
});
}
function handleCreateBackup() {
handleAdd();
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<VbenForm
:handle-submit="handleSubmit"
:model="model"
:schema="formSchemas"
:show-default-actions="false"
@submit="handleSubmit"
>
<template #form-submit>
<div class="flex items-center justify-end space-x-2">
<VbenButton @click="handleCancel" variant="outline">
{{ $t('common.cancel') }}
</VbenButton>
<VbenButton type="primary" @click="handleSubmit">
{{ $t('common.confirm') }}
</VbenButton>
</div>
</template>
</VbenForm>
</div>
</template>
<script lang="ts" setup>
import type { BackupForm } from '../data';
import { VbenButton, VbenForm, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { useBackupFormSchemas } from './formSchemas';
interface Props {
id?: number;
}
interface Emits {
(e: 'submit', data: BackupForm): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
id: undefined,
});
const emit = defineEmits<Emits>();
const [Drawer] = useVbenDrawer();
const model = ref<BackupForm>({
name: '',
type: 'database',
compress: 1,
});
const formSchemas = useBackupFormSchemas();
async function handleSubmit() {
try {
await Drawer?.formApi.validate();
const formValues = Drawer?.formApi.getValues() || model.value;
emit('submit', formValues);
} catch (error) {
console.error('Form validation failed:', error);
}
}
function handleCancel() {
emit('cancel');
}
// Load backup data if editing
onMounted(async () => {
if (props.id) {
try {
// Load backup data
const backupData = await getBackupDetailApi(props.id);
model.value = { ...backupData };
} catch (error) {
console.error('Failed to load backup data:', error);
}
}
});
</script>

View File

@@ -0,0 +1,70 @@
import type { BackupForm } from '../data';
import { useVbenForm } from '@vben/common-ui';
import { $t } from '@vben/locale';
import { typeOptions } from '../data';
export const useBackupFormSchemas = () => {
const formSchemas = computed(() => [
{
component: 'Input',
fieldName: 'name',
label: '备份名称',
rules: 'required',
componentProps: {
placeholder: '请输入备份名称',
},
},
{
component: 'Select',
fieldName: 'type',
label: '备份类型',
rules: 'required',
componentProps: {
options: typeOptions,
placeholder: '请选择备份类型',
},
},
{
component: 'Select',
fieldName: 'tables',
label: '备份表',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'database';
}),
componentProps: {
mode: 'multiple',
placeholder: '请选择要备份的数据表',
options: [], // This should be populated with actual table list
},
},
{
component: 'Select',
fieldName: 'exclude_tables',
label: '排除表',
ifShow: computed(() => {
const form = useVbenForm().getValues();
return form.type === 'database';
}),
componentProps: {
mode: 'multiple',
placeholder: '请选择要排除的数据表',
options: [], // This should be populated with actual table list
},
},
{
component: 'Switch',
fieldName: 'compress',
label: '压缩备份',
defaultValue: 1,
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
},
]);
return formSchemas;
};