feat: 菜单管理

This commit is contained in:
dap 2024-09-22 21:37:32 +08:00
parent e61c3e9058
commit 664e97daa1
5 changed files with 500 additions and 4 deletions

View File

@ -0,0 +1,61 @@
import type { Menu, MenuOption, MenuResp } from './model';
import type { ID, IDS, PageQuery } from '#/api/common';
import { requestClient } from '#/api/request';
enum Api {
menuList = '/system/menu/list',
menuTreeSelect = '/system/menu/treeselect',
roleMenuTree = '/system/menu/roleMenuTreeselect',
root = '/system/menu',
tenantPackageMenuTreeselect = '/system/menu/tenantPackageMenuTreeselect',
}
export function menuList(params?: PageQuery) {
return requestClient.get<Menu[]>(Api.menuList, { params });
}
export function menuInfo(menuId: ID) {
return requestClient.get<Menu>(`${Api.root}/${menuId}`);
}
export function menuAdd(data: any) {
return requestClient.postWithMsg<void>(Api.root, data);
}
export function menuUpdate(data: any) {
return requestClient.putWithMsg<void>(Api.root, data);
}
export function menuRemove(menuIds: IDS) {
return requestClient.deleteWithMsg<void>(`${Api.root}/${menuIds}`);
}
/**
*
* @param roleId id
* @returns resp
*/
export function roleMenuTreeSelect(roleId: ID) {
return requestClient.get<MenuResp>(`${Api.roleMenuTree}/${roleId}`);
}
/**
* 使
* @returns []
*/
export function menuTreeSelect() {
return requestClient.get<MenuOption[]>(Api.menuTreeSelect);
}
/**
* 使
* @param packageId packageId
* @returns resp
*/
export function tenantPackageMenuTreeSelect(packageId: ID) {
return requestClient.get<MenuResp>(
`${Api.tenantPackageMenuTreeselect}/${packageId}`,
);
}

View File

@ -0,0 +1,46 @@
export interface Menu {
createBy?: any;
createTime: string;
updateBy?: any;
updateTime?: any;
remark?: any;
menuId: number;
menuName: string;
parentName?: string;
parentId: number;
orderNum: number;
path: string;
component?: string;
query: string;
isFrame: string;
isCache: string;
menuType: string;
visible: string;
status: string;
perms: string;
icon: string;
children: Menu[];
}
/**
* @description
* @param label
*/
export interface MenuOption {
id: number;
parentId: number;
label: string;
weight: number;
children: MenuOption[];
key: string; // 实际上不存在 ide报错
}
/**
* @description
* @param checkedKeys id
* @param menus
*/
export interface MenuResp {
checkedKeys: number[];
menus: MenuOption[];
}

View File

@ -0,0 +1,252 @@
import { DictEnum } from '@vben/constants';
import { type FormSchemaGetter, z } from '#/adapter';
import { getDictOptions } from '#/utils/dict';
// 菜单类型M目录 C菜单 F按钮
export const menuTypeOptions = [
{ label: '目录', value: 'M' },
{ label: '菜单', value: 'C' },
{ label: '按钮', value: 'F' },
];
export const yesNoOptions = [
{ label: '是', value: '0' },
{ label: '否', value: '1' },
];
export const drawerSchema: FormSchemaGetter = () => [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'menuId',
},
{
component: 'TreeSelect',
componentProps: {
placeholder: '请选择',
},
defaultValue: 0,
fieldName: 'parentId',
label: '上级菜单',
rules: 'selectRequired',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: menuTypeOptions,
optionType: 'button',
placeholder: '请选择',
},
defaultValue: 'M',
dependencies: {
componentProps: (_, api) => {
// 切换时清空校验
// 直接抄的源码 没有清空校验的方法
Object.keys(api.errors.value).forEach((key) => {
api.setFieldError(key, undefined);
});
return {};
},
triggerFields: ['menuType'],
},
fieldName: 'menuType',
label: '菜单类型',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
dependencies: {
// 类型不为按钮时显示
show: (values) => values.menuType !== 'F',
triggerFields: ['menuType'],
},
fieldName: 'icon',
help: '选择或者从 https://icon-sets.iconify.design/ 查找名称粘贴',
label: '菜单图标',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'name',
label: '菜单名称',
rules: 'required',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'orderNum',
help: '排序, 数字越小越靠前',
label: '显示排序',
rules: 'required',
},
{
component: 'Input',
componentProps: (model) => {
const placeholder =
model.isFrame === '0'
? '填写链接地址http(s):// 使用新页面打开'
: '填写`路由地址`或者`链接地址` 链接默认使用内部iframe内嵌打开';
return {
placeholder,
};
},
dependencies: {
rules: (model) => {
if (model.isFrame !== '0') {
return z
.string({ message: '请输入路由地址' })
.refine((val) => !val.startsWith('/'), {
message: '路由地址不需要带/',
});
}
// 为链接
return z
.string({ message: '请输入链接地址' })
.regex(/^https?:\/\//, { message: '请输入正确的链接地址' });
},
// 类型不为按钮时显示
show: (values) => values.menuType !== 'F',
triggerFields: ['isFrame'],
},
fieldName: 'path',
help: `路由地址不带/, 如: menu, user 链接为http(s)://开头 链接默认使用内部iframe打开, 可通过{是否外链}控制打开方式`,
label: '路由地址',
},
{
component: 'Input',
componentProps: (model) => {
return {
// 为链接时组件disabled
disabled: model.isFrame === '0',
placeholder: '请输入',
};
},
defaultValue: '',
dependencies: {
rules: (model) => {
// 非链接时为必填项
if (model.path && !/^https?:\/\//.test(model.path)) {
// TODO 有bug 不会显示此处的校验信息
console.log('非链接时必填组件路径');
return z.string({ message: '非链接时必填组件路径' });
}
return z.string({ message: '请输入' }).optional();
},
// 类型为菜单时显示
show: (values) => values.menuType === 'C',
triggerFields: ['menuType', 'path'],
},
fieldName: 'component',
help: '填写./src/views下的组件路径, 如system/menu/index',
label: '组件路径',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: yesNoOptions,
optionType: 'button',
},
defaultValue: '1',
dependencies: {
// 类型不为按钮时显示
show: (values) => values.menuType !== 'F',
triggerFields: ['menuType'],
},
fieldName: 'isFrame',
help: '外链为http(s)://开头 选择否时, 使用iframe从内部打开页面, 否则新窗口打开',
label: '是否外链',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getDictOptions(DictEnum.SYS_SHOW_HIDE),
optionType: 'button',
},
defaultValue: '0',
dependencies: {
// 类型不为按钮时显示
show: (values) => values.menuType !== 'F',
triggerFields: ['menuType'],
},
fieldName: 'visible',
help: '隐藏后不会出现在菜单栏, 但仍然可以访问',
label: '是否显示',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getDictOptions(DictEnum.SYS_NORMAL_DISABLE),
optionType: 'button',
},
defaultValue: '0',
dependencies: {
// 类型不为按钮时显示
show: (values) => values.menuType !== 'F',
triggerFields: ['menuType'],
},
fieldName: 'status',
help: '停用后不会出现在菜单栏, 也无法访问',
label: '菜单状态',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
dependencies: {
// 类型为菜单/按钮时显示
show: (values) => values.menuType !== 'M',
triggerFields: ['menuType'],
},
fieldName: 'perms',
help: `控制器中定义的权限字符, 如: @SaCheckPermission("system:user:import")`,
label: '权限标识',
},
{
component: 'Input',
componentProps: (model) => ({
// 为链接时组件disabled
disabled: model.isFrame === '0',
placeholder: '请输入',
}),
dependencies: {
// 类型为菜单时显示
show: (values) => values.menuType === 'C',
triggerFields: ['menuType'],
},
fieldName: 'queryParam',
help: 'vue-router中的query属性, 如{"name": "xxx", "age": 16}',
label: '路由参数',
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: yesNoOptions,
optionType: 'button',
},
defaultValue: '0',
dependencies: {
// 类型为菜单时显示
show: (values) => values.menuType === 'C',
triggerFields: ['menuType'],
},
fieldName: 'isCache',
help: '路由的keepAlive属性',
label: '是否缓存',
},
];

View File

@ -1,9 +1,24 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import menuDrawer from './menu-drawer.vue';
const [MenuDrawer, drawerApi] = useVbenDrawer({
connectedComponent: menuDrawer,
});
function handleAdd() {
drawerApi.setData({ update: false });
drawerApi.open();
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page>
<a-button type="primary" @click="handleAdd">
{{ $t('pages.common.add') }}
</a-button>
<MenuDrawer />
</Page>
</template>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { addFullName, getPopupContainer, listToTree } from '@vben/utils';
import { useVbenForm } from '#/adapter';
import { menuAdd, menuList, menuUpdate } from '#/api/system/menu';
import { drawerSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
interface DrawerProps {
update: boolean;
record?: any;
}
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
},
schema: drawerSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function setupMenuSelect() {
// menu
const menuArray = await menuList();
const menuTree = listToTree(menuArray, { id: 'menuId', pid: 'parentId' });
const fullMenuTree = [
{
menuId: 0,
menuName: '根目录',
children: menuTree,
},
];
addFullName(fullMenuTree, 'menuName', ' / ');
await formApi.updateSchema([
{
componentProps: {
fieldNames: {
label: 'menuName',
value: 'menuId',
},
getPopupContainer,
// 256
listHeight: 300,
treeData: fullMenuTree,
treeDefaultExpandAll: false,
//
treeDefaultExpandedKeys: [0],
treeLine: { showLeafIcon: false },
treeNodeLabelProp: 'fullName',
},
fieldName: 'parentId',
},
]);
}
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { record, update } = drawerApi.getData() as DrawerProps;
isUpdate.value = update;
//
await setupMenuSelect();
if (update && record) {
for (const key in record) {
await formApi.setFieldValue(key, record[key]);
}
}
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = await formApi.getValues();
console.log(data);
await (isUpdate.value ? menuUpdate(data) : menuAdd(data));
emit('reload');
await handleCancel();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
}
}
async function handleCancel() {
drawerApi.close();
await formApi.resetForm();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>