Browse Source

chore: demo page menu management (#5619)

* 添加菜单管理演示页面
Netfan 3 weeks ago
parent
commit
5e421ce607

+ 12 - 0
apps/backend-mock/api/system/menu/list.ts

@@ -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);
+});

+ 28 - 0
apps/backend-mock/api/system/menu/name-exists.ts

@@ -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);
+});

+ 28 - 0
apps/backend-mock/api/system/menu/path-exists.ts

@@ -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);
+});

+ 192 - 0
apps/backend-mock/utils/mock-data.ts

@@ -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',
+  },
+];

+ 2 - 2
packages/@core/ui-kit/form-ui/src/form-api.ts

@@ -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) {

+ 3 - 1
packages/locales/src/langs/en-US/common.json

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

+ 4 - 1
packages/locales/src/langs/en-US/ui.json

@@ -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}",

+ 3 - 1
packages/locales/src/langs/zh-CN/common.json

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

+ 4 - 1
packages/locales/src/langs/zh-CN/ui.json

@@ -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}",

+ 4 - 4
playground/src/adapter/vxe-table.ts

@@ -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 },
         );

+ 158 - 0
playground/src/api/system/menu.ts

@@ -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,
+};

+ 39 - 0
playground/src/locales/langs/en-US/system.json

@@ -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"
   }
 }

+ 39 - 0
playground/src/locales/langs/zh-CN/system.json

@@ -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": "系统管理"
 }

+ 11 - 1
playground/src/router/routes/index.ts

@@ -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 };

+ 9 - 0
playground/src/router/routes/modules/system.ts

@@ -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',

+ 109 - 0
playground/src/views/system/menu/data.ts

@@ -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,
+    },
+  ];
+}

+ 162 - 0
playground/src/views/system/menu/list.vue

@@ -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>

+ 521 - 0
playground/src/views/system/menu/modules/form.vue

@@ -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>