This commit is contained in:
dap 2025-03-01 15:22:35 +08:00
commit 43534b6142
28 changed files with 1431 additions and 63 deletions

View File

@ -0,0 +1,12 @@
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
return useResponseSuccess(MOCK_MENU_LIST);
});

View File

@ -0,0 +1,28 @@
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse } from '~/utils/response';
const namesMap: Record<string, any> = {};
function getNames(menus: any[]) {
menus.forEach((menu) => {
namesMap[menu.name] = String(menu.id);
if (menu.children) {
getNames(menu.children);
}
});
}
getNames(MOCK_MENU_LIST);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const { id, name } = getQuery(event);
return (name as string) in namesMap &&
(!id || namesMap[name as string] !== String(id))
? useResponseSuccess(true)
: useResponseSuccess(false);
});

View File

@ -0,0 +1,28 @@
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse } from '~/utils/response';
const pathMap: Record<string, any> = { '/': 0 };
function getPaths(menus: any[]) {
menus.forEach((menu) => {
pathMap[menu.path] = String(menu.id);
if (menu.children) {
getPaths(menu.children);
}
});
}
getPaths(MOCK_MENU_LIST);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const { id, path } = getQuery(event);
return (path as string) in pathMap &&
(!id || pathMap[path as string] !== String(id))
? useResponseSuccess(true)
: useResponseSuccess(false);
});

View File

@ -185,3 +185,195 @@ export const MOCK_MENUS = [
username: 'jack',
},
];
export const MOCK_MENU_LIST = [
{
id: 1,
name: 'Workspace',
status: 1,
type: 'menu',
icon: 'mdi:dashboard',
path: '/workspace',
component: '/dashboard/workspace/index',
meta: {
icon: 'carbon:workspace',
title: 'page.dashboard.workspace',
affixTab: true,
order: 0,
},
},
{
id: 2,
meta: {
icon: 'carbon:settings',
order: 9997,
title: 'system.title',
badge: 'new',
badgeType: 'normal',
badgeVariants: 'primary',
},
status: 1,
type: 'catalog',
name: 'System',
path: '/system',
children: [
{
id: 201,
pid: 2,
path: '/system/menu',
name: 'SystemMenu',
authCode: 'System:Menu:List',
status: 1,
type: 'menu',
meta: {
icon: 'carbon:menu',
title: 'system.menu.title',
},
component: '/system/menu/list',
children: [
{
id: 20_101,
pid: 201,
name: 'SystemMenuCreate',
status: 1,
type: 'button',
authCode: 'System:Menu:Create',
meta: { title: 'common.create' },
},
{
id: 20_102,
pid: 201,
name: 'SystemMenuEdit',
status: 1,
type: 'button',
authCode: 'System:Menu:Edit',
meta: { title: 'common.edit' },
},
{
id: 20_103,
pid: 201,
name: 'SystemMenuDelete',
status: 1,
type: 'button',
authCode: 'System:Menu:Delete',
meta: { title: 'common.delete' },
},
],
},
{
id: 202,
pid: 2,
path: '/system/dept',
name: 'SystemDept',
status: 1,
type: 'menu',
authCode: 'System:Dept:List',
meta: {
icon: 'carbon:container-services',
title: 'system.dept.title',
},
component: '/system/dept/list',
children: [
{
id: 20_401,
pid: 201,
name: 'SystemDeptCreate',
status: 1,
type: 'button',
authCode: 'System:Dept:Create',
meta: { title: 'common.create' },
},
{
id: 20_402,
pid: 201,
name: 'SystemDeptEdit',
status: 1,
type: 'button',
authCode: 'System:Dept:Edit',
meta: { title: 'common.edit' },
},
{
id: 20_403,
pid: 201,
name: 'SystemDeptDelete',
status: 1,
type: 'button',
authCode: 'System:Dept:Delete',
meta: { title: 'common.delete' },
},
],
},
],
},
{
id: 9,
meta: {
badgeType: 'dot',
order: 9998,
title: 'demos.vben.title',
icon: 'carbon:data-center',
},
name: 'Project',
path: '/vben-admin',
type: 'catalog',
status: 1,
children: [
{
id: 901,
pid: 9,
name: 'VbenDocument',
path: '/vben-admin/document',
component: 'IFrameView',
type: 'embedded',
status: 1,
meta: {
icon: 'carbon:book',
iframeSrc: 'https://doc.vben.pro',
title: 'demos.vben.document',
},
},
{
id: 902,
pid: 9,
name: 'VbenGithub',
path: '/vben-admin/github',
component: 'IFrameView',
type: 'link',
status: 1,
meta: {
icon: 'carbon:logo-github',
link: 'https://github.com/vbenjs/vue-vben-admin',
title: 'Github',
},
},
{
id: 903,
pid: 9,
name: 'VbenAntdv',
path: '/vben-admin/antdv',
component: 'IFrameView',
type: 'link',
status: 0,
meta: {
icon: 'carbon:hexagon-vertical-solid',
badgeType: 'dot',
link: 'https://ant.vben.pro',
title: 'demos.vben.antdv',
},
},
],
},
{
id: 10,
component: '_core/about/index',
type: 'menu',
status: 1,
meta: {
icon: 'lucide:copyright',
order: 9999,
title: 'demos.vben.about',
},
name: 'About',
path: '/about',
},
];

View File

@ -518,20 +518,25 @@ import { z } from '#/adapter/form';
// 可选(可以是undefined)并且携带默认值。注意zod的optional不包括空字符串''
{
rules: z.string().default('默认值').optional(),
rules: z.string().default('默认值').optional();
}
// 可以是空字符串、undefined或者一个邮箱地址
// 可以是空字符串、undefined或者一个邮箱地址(两种不同的用法)
{
rules: z.union(z.string().email().optional(), z.literal(""))
rules: z.union([z.string().email().optional(), z.literal('')]);
}
{
rules: z.string().email().or(z.literal('')).optional();
}
// 复杂校验
{
z.string().min(1, { message: "请输入" })
.refine((value) => value === "123", {
message: "值必须为123",
});
z.string()
.min(1, { message: '请输入' })
.refine((value) => value === '123', {
message: '值必须为123',
});
}
```

View File

@ -95,7 +95,7 @@
"node": ">=20.10.0",
"pnpm": ">=9.12.0"
},
"packageManager": "pnpm@9.15.5",
"packageManager": "pnpm@9.15.6",
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {

View File

@ -93,9 +93,9 @@ export class FormApi {
return this.state;
}
async getValues() {
async getValues<T = Recordable<any>>() {
const form = await this.getForm();
return form.values ? this.handleRangeTimeValue(form.values) : {};
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
}
async isFieldValid(fieldName: string) {

View File

@ -38,6 +38,7 @@ export class DrawerApi {
const defaultState: DrawerState = {
class: '',
closable: true,
closeIconPlacement: 'right',
closeOnClickModal: true,
closeOnPressEscape: true,
confirmLoading: false,

View File

@ -55,6 +55,7 @@ const {
cancelText,
class: drawerClass,
closable,
closeIconPlacement,
closeOnClickModal,
closeOnPressEscape,
confirmLoading,

View File

@ -3,8 +3,8 @@ import type { BreadcrumbProps } from './types';
import { useForwardPropsEmits } from 'radix-vue';
import Breadcrumb from './breadcrumb.vue';
import BreadcrumbBackground from './breadcrumb-background.vue';
import Breadcrumb from './breadcrumb.vue';
interface Props extends BreadcrumbProps {
class?: any;
@ -17,6 +17,23 @@ const emit = defineEmits<{ select: [string] }>();
const forward = useForwardPropsEmits(props, emit);
</script>
<template>
<Breadcrumb v-if="styleType === 'normal'" v-bind="forward" />
<BreadcrumbBackground v-if="styleType === 'background'" v-bind="forward" />
<Breadcrumb
v-if="styleType === 'normal'"
v-bind="forward"
class="vben-breadcrumb"
/>
<BreadcrumbBackground
v-if="styleType === 'background'"
v-bind="forward"
class="vben-breadcrumb"
/>
</template>
<style lang="scss" scoped>
/** 修复全局引入Antd时ol和ul的默认样式会被修改的问题 */
.vben-breadcrumb {
:deep(ol),
:deep(ul) {
margin-bottom: 0;
}
}
</style>

View File

@ -66,7 +66,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
>
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
<p v-if="appName" class="text-xl font-medium">
<p v-if="appName" class="m-0 text-xl font-medium">
{{ appName }}
</p>
</div>

View File

@ -16,5 +16,7 @@
"disabled": "Disabled",
"edit": "Edit",
"delete": "Delete",
"create": "Create"
"create": "Create",
"yes": "Yes",
"no": "No"
}

View File

@ -4,7 +4,10 @@
"selectRequired": "Please select {0}",
"minLength": "{0} must be at least {1} characters",
"maxLength": "{0} can be at most {1} characters",
"length": "{0} must be {1} characters long"
"length": "{0} must be {1} characters long",
"alreadyExists": "{0} `{1}` already exists",
"startWith": "{0} must start with `{1}`",
"invalidURL": "Please input a valid URL"
},
"actionTitle": {
"edit": "Modify {0}",

View File

@ -16,5 +16,7 @@
"disabled": "已禁用",
"edit": "修改",
"delete": "删除",
"create": "新增"
"create": "新增",
"yes": "是",
"no": "否"
}

View File

@ -4,7 +4,10 @@
"selectRequired": "请选择{0}",
"minLength": "{0}至少{1}个字符",
"maxLength": "{0}最多{1}个字符",
"length": "{0}长度必须为{1}个字符"
"length": "{0}长度必须为{1}个字符",
"alreadyExists": "{0} `{1}` 已存在",
"startWith": "{0}必须以 {1} 开头",
"invalidURL": "请输入有效的链接"
},
"actionTitle": {
"edit": "修改{0}",

View File

@ -5,7 +5,7 @@ import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { isFunction, isString } from '@vben/utils';
import { get, isFunction, isString } from '@vben/utils';
import { objectOmit } from '@vueuse/core';
import { Button, Image, Popconfirm, Tag } from 'ant-design-vue';
@ -77,8 +77,8 @@ setupVbenVxeTable({
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = row[column.field];
const tagOptions = options || [
const value = get(row, column.field);
const tagOptions = options ?? [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'error', label: $t('common.disabled'), value: 0 },
];
@ -87,7 +87,7 @@ setupVbenVxeTable({
Tag,
{
...props,
...objectOmit(tagItem, ['label']),
...objectOmit(tagItem ?? {}, ['label']),
},
{ default: () => tagItem?.label ?? value },
);

View File

@ -0,0 +1,158 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemMenuApi {
/** 徽标颜色集合 */
export const BadgeVariants = [
'default',
'destructive',
'primary',
'success',
'warning',
] as const;
/** 徽标类型集合 */
export const BadgeTypes = ['dot', 'normal'] as const;
/** 菜单类型集合 */
export const MenuTypes = [
'catalog',
'menu',
'embedded',
'link',
'button',
] as const;
/** 系统菜单 */
export interface SystemMenu {
[key: string]: any;
/** 后端权限标识 */
authCode: string;
/** 子级 */
children?: SystemMenu[];
/** 组件 */
component?: string;
/** 菜单ID */
id: string;
/** 菜单元数据 */
meta?: {
/** 激活时显示的图标 */
activeIcon?: string;
/** 作为路由时需要激活的菜单的Path */
activePath?: string;
/** 固定在标签栏 */
affixTab?: boolean;
/** 在标签栏固定的顺序 */
affixTabOrder?: number;
/** 徽标内容(当徽标类型为normal时有效) */
badge?: string;
/** 徽标类型 */
badgeType?: (typeof BadgeTypes)[number];
/** 徽标颜色 */
badgeVariants?: (typeof BadgeVariants)[number];
/** 在菜单中隐藏下级 */
hideChildrenInMenu?: boolean;
/** 在面包屑中隐藏 */
hideInBreadcrumb?: boolean;
/** 在菜单中隐藏 */
hideInMenu?: boolean;
/** 在标签栏中隐藏 */
hideInTab?: boolean;
/** 菜单图标 */
icon?: string;
/** 内嵌Iframe的URL */
iframeSrc?: string;
/** 是否缓存页面 */
keepAlive?: boolean;
/** 外链页面的URL */
link?: string;
/** 同一个路由最大打开的标签数 */
maxNumOfOpenTab?: number;
/** 无需基础布局 */
noBasicLayout?: boolean;
/** 是否在新窗口打开 */
openInNewWindow?: boolean;
/** 菜单排序 */
order?: number;
/** 额外的路由参数 */
query?: Recordable<any>;
/** 菜单标题 */
title?: string;
};
/** 菜单名称 */
name: string;
/** 路由路径 */
path: string;
/** 父级ID */
pid: string;
/** 重定向 */
redirect?: string;
/** 菜单类型 */
type: (typeof MenuTypes)[number];
}
}
/**
*
*/
async function getMenuList() {
return requestClient.get<Array<SystemMenuApi.SystemMenu>>(
'/system/menu/list',
);
}
async function isMenuNameExists(
name: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/name-exists', {
params: { id, name },
});
}
async function isMenuPathExists(
path: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/path-exists', {
params: { id, path },
});
}
/**
*
* @param data
*/
async function createMenu(
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.post('/system/menu', data);
}
/**
*
*
* @param id ID
* @param data
*/
async function updateMenu(
id: string,
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.put(`/system/menu/${id}`, data);
}
/**
*
* @param id ID
*/
async function deleteMenu(id: string) {
return requestClient.delete(`/system/menu/${id}`);
}
export {
createMenu,
deleteMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
updateMenu,
};

View File

@ -9,5 +9,44 @@
"remark": "Remark",
"operation": "Operation",
"parentDept": "Parent Department"
},
"menu": {
"title": "Menu Management",
"parent": "Parent Menu",
"menuTitle": "Title",
"menuName": "Menu Name",
"name": "Menu",
"type": "Type",
"typeCatalog": "Catalog",
"typeMenu": "Menu",
"typeButton": "Button",
"typeLink": "Link",
"typeEmbedded": "Embedded",
"icon": "Icon",
"activeIcon": "Active Icon",
"activePath": "Active Path",
"path": "Route Path",
"component": "Component",
"status": "Status",
"authCode": "Auth Code",
"badge": "Badge",
"operation": "Operation",
"linkSrc": "Link Address",
"affixTab": "Affix In Tabs",
"keepAlive": "Keep Alive",
"hideInMenu": "Hide In Menu",
"hideInTab": "Hide In Tabbar",
"hideChildrenInMenu": "Hide Children In Menu",
"hideInBreadcrumb": "Hide In Breadcrumb",
"advancedSettings": "Other Settings",
"activePathMustExist": "The path could not find a valid menu",
"activePathHelp": "When jumping to the current route, \nthe menu path that needs to be activated must be specified when it does not display in the navigation menu.",
"badgeType": {
"title": "Badge Type",
"dot": "Dot",
"normal": "Text",
"none": "None"
},
"badgeVariants": "Badge Style"
}
}

View File

@ -9,5 +9,44 @@
"status": "状态",
"title": "部门管理"
},
"menu": {
"activeIcon": "激活图标",
"activePath": "激活路径",
"activePathHelp": "跳转到当前路由时,需要激活的菜单路径。\n当不在导航菜单中显示时需要指定激活路径",
"activePathMustExist": "该路径未能找到有效的菜单",
"advancedSettings": "其它设置",
"affixTab": "固定在标签",
"authCode": "权限标识",
"badge": "徽章内容",
"badgeVariants": "徽标样式",
"badgeType": {
"dot": "点",
"none": "无",
"normal": "文字",
"title": "徽标类型"
},
"component": "页面组件",
"hideChildrenInMenu": "隐藏子菜单",
"hideInBreadcrumb": "在面包屑中隐藏",
"hideInMenu": "隐藏菜单",
"hideInTab": "在标签栏中隐藏",
"icon": "图标",
"keepAlive": "缓存标签页",
"linkSrc": "链接地址",
"menuName": "菜单名称",
"menuTitle": "标题",
"name": "菜单",
"operation": "操作",
"parent": "上级菜单",
"path": "路由地址",
"status": "状态",
"title": "菜单管理",
"type": "类型",
"typeButton": "按钮",
"typeCatalog": "目录",
"typeEmbedded": "内嵌",
"typeLink": "外链",
"typeMenu": "菜单"
},
"title": "系统管理"
}

View File

@ -34,4 +34,14 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
export { accessRoutes, coreRouteNames, routes };
const componentKeys: string[] = Object.keys(
import.meta.glob('../../views/**/*.vue'),
)
.filter((item) => !item.includes('/modules/'))
.map((v) => {
const path = v.replace('../../views/', '/');
return path.endsWith('.vue') ? path.slice(0, -4) : path;
});
export { accessRoutes, componentKeys, coreRouteNames, routes };

View File

@ -147,8 +147,6 @@ const routes: RouteRecordRaw[] = [
{
name: 'HideChildrenInMenuParentDemo',
path: '/demos/features/hide-menu-children',
component: () =>
import('#/views/demos/features/hide-menu-children/parent.vue'),
meta: {
hideChildrenInMenu: true,
icon: 'ic:round-menu',
@ -160,10 +158,10 @@ const routes: RouteRecordRaw[] = [
path: '',
component: () =>
import(
'#/views/demos/features/hide-menu-children/children.vue'
'#/views/demos/features/hide-menu-children/parent.vue'
),
meta: {
hideInMenu: true,
// hideInMenu: true,
title: $t('demos.features.hideChildrenInMenu'),
},
},
@ -174,7 +172,10 @@ const routes: RouteRecordRaw[] = [
import(
'#/views/demos/features/hide-menu-children/children.vue'
),
meta: { title: $t('demos.features.hideChildrenInMenu') },
meta: {
activePath: '/demos/features/hide-menu-children',
title: $t('demos.features.hideChildrenInMenu'),
},
},
],
},

View File

@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
name: 'System',
path: '/system',
children: [
{
path: '/system/menu',
name: 'SystemMenu',
meta: {
icon: 'mdi:menu',
title: $t('system.menu.title'),
},
component: () => import('#/views/system/menu/list.vue'),
},
{
path: '/system/dept',
name: 'SystemDept',

View File

@ -1,3 +1,23 @@
<script lang="ts" setup>
import { Fallback, VbenButton } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { X } from '@vben/icons';
const { closeCurrentTab } = useTabs();
</script>
<template>
<div>children</div>
<Fallback
description="当前路由在菜单中不可见"
status="coming-soon"
title="被隐藏的子菜单"
show-back
>
<template #action>
<VbenButton size="lg" @click="closeCurrentTab()">
<X class="mr-2 size-4" />
关闭当前标签页
</VbenButton>
</template>
</Fallback>
</template>

View File

@ -4,8 +4,14 @@ import { Fallback } from '@vben/common-ui';
<template>
<Fallback
description="当前菜单的子菜单不可见"
:description="`当前路由:${String($route.name)},子菜单不可见`"
status="coming-soon"
title="隐藏子菜单"
/>
>
<template #action>
<RouterLink to="/demos/features/hide-menu-children/children">
打开子路由
</RouterLink>
</template>
</Fallback>
</template>

View File

@ -0,0 +1,109 @@
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { $t } from '#/locales';
export function getMenuTypeOptions() {
return [
{
color: 'processing',
label: $t('system.menu.typeCatalog'),
value: 'catalog',
},
{ color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' },
{ color: 'error', label: $t('system.menu.typeButton'), value: 'button' },
{
color: 'success',
label: $t('system.menu.typeEmbedded'),
value: 'embedded',
},
{ color: 'warning', label: $t('system.menu.typeLink'), value: 'link' },
];
}
export function useColumns(
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
return [
{
align: 'left',
field: 'meta.title',
fixed: 'left',
slots: { default: 'title' },
title: $t('system.menu.menuTitle'),
treeNode: true,
width: 250,
},
{
align: 'center',
cellRender: { name: 'CellTag', options: getMenuTypeOptions() },
field: 'type',
title: $t('system.menu.type'),
width: 100,
},
{
field: 'authCode',
title: $t('system.menu.authCode'),
width: 200,
},
{
align: 'left',
field: 'path',
title: $t('system.menu.path'),
width: 200,
},
{
align: 'left',
field: 'component',
formatter: ({ row }) => {
switch (row.type) {
case 'catalog':
case 'menu': {
return row.component ?? '';
}
case 'embedded': {
return row.meta?.iframeSrc ?? '';
}
case 'link': {
return row.meta?.link ?? '';
}
}
return '';
},
minWidth: 200,
title: $t('system.menu.component'),
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.menu.status'),
width: 100,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
},
'edit', // 默认的编辑按钮
'delete', // 默认的删除按钮
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.menu.operation'),
width: 200,
},
];
}

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon, Plus } from '@vben/icons';
import { $t } from '@vben/locales';
import { MenuBadge } from '@vben-core/menu-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMenu, getMenuList, SystemMenuApi } from '#/api/system/menu';
import { useColumns } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getMenuList();
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
});
function onActionClick({
code,
row,
}: OnActionClickParams<SystemMenuApi.SystemMenu>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
default: {
break;
}
}
}
function onRefresh() {
gridApi.query();
}
function onEdit(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData(row).open();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
function onAppend(row: SystemMenuApi.SystemMenu) {
formDrawerApi.setData({ pid: row.id }).open();
}
function onDelete(row: SystemMenuApi.SystemMenu) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteMenu(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid>
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
</Button>
</template>
<template #title="{ row }">
<div class="flex w-full items-center gap-1">
<div class="size-5 flex-shrink-0">
<IconifyIcon
v-if="row.type === 'button'"
icon="carbon:security"
class="size-full"
/>
<IconifyIcon
v-else-if="row.meta?.icon"
:icon="row.meta?.icon || 'carbon:circle-dash'"
class="size-full"
/>
</div>
<span class="flex-auto">{{ $t(row.meta?.title) }}</span>
<div class="items-center justify-end"></div>
</div>
<MenuBadge
v-if="row.meta?.badgeType"
class="menu-badge"
:badge="row.meta.badge"
:badge-type="row.meta.badgeType"
:badge-variants="row.meta.badgeVariants"
/>
</template>
</Grid>
</Page>
</template>
<style lang="scss" scoped>
.menu-badge {
top: 50%;
right: 0;
transform: translateY(-50%);
& > :deep(div) {
padding-top: 0;
padding-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,521 @@
<script lang="ts" setup>
import type { ChangeEvent } from 'ant-design-vue/es/_util/EventInterface';
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, h, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import { getPopupContainer } from '@vben/utils';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
import { useVbenForm, z } from '#/adapter/form';
import {
createMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
SystemMenuApi,
updateMenu,
} from '#/api/system/menu';
import { $t } from '#/locales';
import { componentKeys } from '#/router/routes';
import { getMenuTypeOptions } from '../data';
const emit = defineEmits<{
success: [];
}>();
const formData = ref<SystemMenuApi.SystemMenu>();
const loading = ref(false);
const titleSuffix = ref<string>();
const schema: VbenFormSchema[] = [
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getMenuTypeOptions(),
optionType: 'button',
},
defaultValue: 'menu',
fieldName: 'type',
formItemClass: 'col-span-2 md:col-span-2',
label: $t('system.menu.type'),
},
{
component: 'Input',
fieldName: 'name',
label: $t('system.menu.menuName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.menuName'), 2]))
.max(30, $t('ui.formRules.maxLength', [$t('system.menu.menuName'), 30]))
.refine(
async (value: string) => {
return !(await isMenuNameExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.menuName'),
value,
]),
}),
),
},
{
component: 'ApiTreeSelect',
componentProps: {
api: getMenuList,
class: 'w-full',
filterTreeNode(input: string, node: Recordable<any>) {
if (!input || input.length === 0) {
return true;
}
const title: string = node.meta?.title ?? '';
if (!title) return false;
return title.includes(input) || $t(title).includes(input);
},
getPopupContainer,
labelField: 'meta.title',
showSearch: true,
treeDefaultExpandAll: true,
valueField: 'id',
childrenField: 'children',
},
fieldName: 'pid',
label: $t('system.menu.parent'),
renderComponentContent() {
return {
title({ label, meta }: { label: string; meta: Recordable<any> }) {
const coms = [];
if (!label) return '';
if (meta?.icon) {
coms.push(h(IconifyIcon, { class: 'size-4', icon: meta.icon }));
}
coms.push(h('span', { class: '' }, $t(label || '')));
return h('div', { class: 'flex items-center gap-1' }, coms);
},
};
},
},
{
component: 'Input',
componentProps() {
//
return {
addonAfter: titleSuffix.value,
onChange({ target: { value } }: ChangeEvent) {
titleSuffix.value = value && $te(value) ? $t(value) : undefined;
},
};
},
fieldName: 'meta.title',
label: $t('system.menu.menuTitle'),
rules: 'required',
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'path',
label: $t('system.menu.path'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
)
.refine(
async (value: string) => {
return !(await isMenuPathExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.path'),
value,
]),
}),
),
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'activePath',
help: $t('system.menu.activePathHelp'),
label: $t('system.menu.activePath'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
)
.refine(async (value: string) => {
return await isMenuPathExists(value, formData.value?.id);
}, $t('system.menu.activePathMustExist'))
.optional(),
},
{
component: 'IconPicker',
componentProps: {
prefix: 'carbon',
},
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'link', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.icon',
label: $t('system.menu.icon'),
},
{
component: 'IconPicker',
componentProps: {
prefix: 'carbon',
},
dependencies: {
show: (values) => {
return ['catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.activeIcon',
label: $t('system.menu.activeIcon'),
},
{
component: 'AutoComplete',
componentProps: {
allowClear: true,
class: 'w-full',
filterOption(input: string, option: { value: string }) {
return option.value.toLowerCase().includes(input.toLowerCase());
},
options: componentKeys.map((v) => ({ value: v })),
},
dependencies: {
rules: (values) => {
return values.type === 'menu' ? 'required' : null;
},
show: (values) => {
return values.type === 'menu';
},
triggerFields: ['type'],
},
fieldName: 'component',
label: $t('system.menu.component'),
},
{
component: 'Input',
dependencies: {
show: (values) => {
return ['embedded', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'linkSrc',
label: $t('system.menu.linkSrc'),
rules: z.string().url($t('ui.formRules.invalidURL')),
},
{
component: 'Input',
dependencies: {
rules: (values) => {
return values.type === 'button' ? 'required' : null;
},
show: (values) => {
return ['button', 'catalog', 'embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'authCode',
label: $t('system.menu.authCode'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.menu.status'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: [
{ label: $t('system.menu.badgeType.dot'), value: 'dot' },
{ label: $t('system.menu.badgeType.normal'), value: 'normal' },
],
},
dependencies: {
show: (values) => {
return values.type !== 'button';
},
triggerFields: ['type'],
},
fieldName: 'meta.badgeType',
label: $t('system.menu.badgeType.title'),
},
{
component: 'Input',
componentProps: (values) => {
return {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
};
},
dependencies: {
show: (values) => {
return values.type !== 'button';
},
triggerFields: ['type'],
},
fieldName: 'meta.badge',
label: $t('system.menu.badge'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
class: 'w-full',
options: SystemMenuApi.BadgeVariants.map((v) => ({
label: v,
value: v,
})),
},
dependencies: {
show: (values) => {
return values.type !== 'button';
},
triggerFields: ['type'],
},
fieldName: 'meta.badgeVariants',
label: $t('system.menu.badgeVariants'),
},
{
component: 'Divider',
dependencies: {
show: (values) => {
return !['button', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'divider1',
formItemClass: 'col-span-2 md:col-span-2 pb-0',
hideLabel: true,
renderComponentContent() {
return {
default: () => $t('system.menu.advancedSettings'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.keepAlive',
renderComponentContent() {
return {
default: () => $t('system.menu.keepAlive'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['embedded', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.affixTab',
renderComponentContent() {
return {
default: () => $t('system.menu.affixTab'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['button'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInMenu',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInMenu'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return ['catalog', 'menu'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideChildrenInMenu',
renderComponentContent() {
return {
default: () => $t('system.menu.hideChildrenInMenu'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['button', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInBreadcrumb',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInBreadcrumb'),
};
},
},
{
component: 'Checkbox',
dependencies: {
show: (values) => {
return !['button', 'link'].includes(values.type);
},
triggerFields: ['type'],
},
fieldName: 'meta.hideInTab',
renderComponentContent() {
return {
default: () => $t('system.menu.hideInTab'),
};
},
},
];
const breakpoints = useBreakpoints(breakpointsTailwind);
const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
const [Form, formApi] = useVbenForm({
commonConfig: {
colon: true,
formItemClass: 'col-span-2 md:col-span-1',
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-2 gap-x-4',
});
const [Drawer, drawerApi] = useVbenDrawer({
onBeforeClose() {
if (loading.value) return false;
},
onConfirm: onSubmit,
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemMenuApi.SystemMenu>();
if (data?.type === 'link') {
data.linkSrc = data.meta?.link;
} else if (data?.type === 'embedded') {
data.linkSrc = data.meta?.iframeSrc;
}
if (data) {
formData.value = data;
formApi.setValues(formData.value);
titleSuffix.value = formData.value.meta?.title
? $t(formData.value.meta.title)
: '';
} else {
formApi.resetForm();
titleSuffix.value = '';
}
}
},
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
loading.value = true;
drawerApi.setState({
closeOnClickModal: false,
closeOnPressEscape: false,
confirmLoading: true,
loading: true,
});
const data =
await formApi.getValues<
Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>
>();
if (data.type === 'link') {
data.meta = { ...data.meta, link: data.linkSrc };
} else if (data.type === 'embedded') {
data.meta = { ...data.meta, iframeSrc: data.linkSrc };
}
delete data.linkSrc;
try {
await (formData.value?.id
? updateMenu(formData.value.id, data)
: createMenu(data));
drawerApi.close();
emit('success');
} finally {
loading.value = false;
drawerApi.setState({
closeOnClickModal: true,
closeOnPressEscape: true,
confirmLoading: false,
loading: false,
});
}
}
}
const getDrawerTitle = computed(() =>
formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.menu.name')])
: $t('ui.actionTitle.create', [$t('system.menu.name')]),
);
</script>
<template>
<Drawer class="w-full max-w-[800px]" :title="getDrawerTitle">
<Form class="mx-4" :layout="isHorizontal ? 'horizontal' : 'vertical'" />
</Drawer>
</template>

View File

@ -14,44 +14,44 @@ packages:
- playground
catalog:
'@ast-grep/napi': ^0.32.3
'@changesets/changelog-github': ^0.5.0
'@changesets/cli': ^2.27.12
'@changesets/changelog-github': ^0.5.1
'@changesets/cli': ^2.28.1
'@changesets/git': ^3.0.2
'@clack/prompts': ^0.9.1
'@commitlint/cli': ^19.7.1
'@commitlint/config-conventional': ^19.7.1
'@ctrl/tinycolor': ^4.1.0
'@eslint/js': ^9.20.0
'@faker-js/faker': ^9.5.0
'@iconify/json': ^2.2.307
'@eslint/js': ^9.21.0
'@faker-js/faker': ^9.5.1
'@iconify/json': ^2.2.311
'@iconify/tailwind': ^1.2.0
'@iconify/vue': ^4.3.0
'@intlify/core-base': ^11.1.1
'@intlify/unplugin-vue-i18n': ^6.0.3
'@jspm/generator': ^2.5.0
'@jspm/generator': ^2.5.1
'@manypkg/get-packages': ^2.2.2
'@nolebase/vitepress-plugin-git-changelog': ^2.14.0
'@nolebase/vitepress-plugin-git-changelog': ^2.15.0
'@playwright/test': ^1.50.1
'@pnpm/workspace.read-manifest': ^1000.0.2
'@stylistic/stylelint-plugin': ^3.1.1
'@pnpm/workspace.read-manifest': ^1000.1.0
'@stylistic/stylelint-plugin': ^3.1.2
'@tailwindcss/nesting': 0.0.0-insiders.565cd3e
'@tailwindcss/typography': ^0.5.16
'@tanstack/vue-query': ^5.66.3
'@tanstack/vue-query': ^5.66.9
'@tanstack/vue-store': ^0.7.0
'@types/archiver': ^6.0.3
'@types/eslint': ^9.6.1
'@types/html-minifier-terser': ^7.0.2
'@types/jsonwebtoken': ^9.0.8
'@types/jsonwebtoken': ^9.0.9
'@types/lodash.clonedeep': ^4.5.9
'@types/lodash.get': ^4.4.9
'@types/lodash.isequal': ^4.5.8
'@types/node': ^22.13.4
'@types/node': ^22.13.5
'@types/nprogress': ^0.2.3
'@types/postcss-import': ^14.0.3
'@types/qrcode': ^1.5.5
'@types/sortablejs': ^1.15.8
'@typescript-eslint/eslint-plugin': ^8.24.0
'@typescript-eslint/parser': ^8.24.0
'@typescript-eslint/eslint-plugin': ^8.25.0
'@typescript-eslint/parser': ^8.25.0
'@vee-validate/zod': ^4.15.0
'@vite-pwa/vitepress': ^0.5.3
'@vitejs/plugin-vue': ^5.2.1
@ -65,7 +65,7 @@ catalog:
ant-design-vue: ^4.2.6
archiver: ^7.0.1
autoprefixer: ^10.4.20
axios: ^1.7.9
axios: ^1.8.1
axios-mock-adapter: ^2.1.0
cac: ^6.7.14
chalk: ^5.4.1
@ -85,9 +85,9 @@ catalog:
depcheck: ^1.4.7
dotenv: ^16.4.7
echarts: ^5.6.0
element-plus: ^2.9.4
eslint: ^9.20.1
eslint-config-turbo: ^2.4.2
element-plus: ^2.9.5
eslint: ^9.21.0
eslint-config-turbo: ^2.4.4
eslint-plugin-command: ^0.2.7
eslint-plugin-eslint-comments: ^3.2.0
eslint-plugin-import-x: ^4.6.1
@ -106,7 +106,7 @@ catalog:
find-up: ^7.0.0
get-port: ^7.1.0
globals: ^15.15.0
h3: ^1.15.0
h3: ^1.15.1
happy-dom: ^16.8.1
html-minifier-terser: ^7.2.0
husky: ^9.1.7
@ -127,22 +127,22 @@ catalog:
pinia-plugin-persistedstate: ^4.2.0
pkg-types: ^1.3.1
playwright: ^1.50.1
postcss: ^8.5.2
postcss: ^8.5.3
postcss-antd-fixes: ^0.2.0
postcss-html: ^1.8.0
postcss-import: ^16.1.0
postcss-preset-env: ^10.1.4
postcss-preset-env: ^10.1.5
postcss-scss: ^4.0.9
prettier: ^3.5.1
prettier: ^3.5.2
prettier-plugin-tailwindcss: ^0.6.11
publint: ^0.2.12
qrcode: ^1.5.4
radix-vue: ^1.9.14
radix-vue: ^1.9.17
resolve.exports: ^2.0.3
rimraf: ^6.0.1
rollup: ^4.34.7
rollup: ^4.34.8
rollup-plugin-visualizer: ^5.14.0
sass: ^1.85.0
sass: ^1.85.1
sortablejs: ^1.15.6
stylelint: ^16.14.1
stylelint-config-recess-order: ^5.1.1
@ -152,26 +152,26 @@ catalog:
stylelint-config-standard: ^36.0.1
stylelint-order: ^6.0.4
stylelint-prettier: ^5.0.3
stylelint-scss: ^6.11.0
stylelint-scss: ^6.11.1
tailwind-merge: ^2.6.0
tailwindcss: ^3.4.17
tailwindcss-animate: ^1.0.7
theme-colors: ^0.1.0
tippy.js: ^6.2.5
turbo: ^2.4.2
turbo: ^2.4.4
typescript: ^5.7.3
unbuild: ^3.3.1
unbuild: ^3.5.0
unplugin-element-plus: ^0.9.1
vee-validate: ^4.15.0
vite: ^6.1.0
vite: ^6.2.0
vite-plugin-compression: ^0.5.1
vite-plugin-dts: ^4.5.0
vite-plugin-dts: ^4.5.1
vite-plugin-html: ^3.2.2
vite-plugin-lazy-import: ^1.0.7
vite-plugin-pwa: ^0.21.1
vite-plugin-vue-devtools: ^7.7.2
vitepress: ^1.6.3
vitepress-plugin-group-icons: ^1.3.5
vitepress-plugin-group-icons: ^1.3.6
vitest: ^2.1.9
vue: ^3.5.13
vue-eslint-parser: ^9.4.3
@ -180,7 +180,7 @@ catalog:
vue-router: ^4.5.0
vue-tippy: ^6.6.0
vue-tsc: 2.1.10
vxe-pc-ui: ^4.3.87
vxe-pc-ui: ^4.3.99
vxe-table: 4.10.0
watermark-js-plus: ^1.5.8
zod: ^3.24.2