DESKTOP-USV654P\pc il y a 11 mois
Parent
commit
369d22a17d
79 fichiers modifiés avec 3172 ajouts et 500 suppressions
  1. 12 0
      apps/backend-mock/api/system/menu/list.ts
  2. 28 0
      apps/backend-mock/api/system/menu/name-exists.ts
  3. 28 0
      apps/backend-mock/api/system/menu/path-exists.ts
  4. 83 0
      apps/backend-mock/api/system/role/list.ts
  5. 1 1
      apps/backend-mock/api/table/list.ts
  6. 1 1
      apps/backend-mock/middleware/1.api.ts
  7. 1 1
      apps/backend-mock/utils/cookie-utils.ts
  8. 203 0
      apps/backend-mock/utils/mock-data.ts
  9. 27 6
      apps/web-antd/src/adapter/component/index.ts
  10. 27 6
      apps/web-baicai/src/adapter/component/index.ts
  11. 30 2
      apps/web-baicai/src/adapter/vxe-table.ts
  12. 19 3
      apps/web-baicai/src/views/_core/authentication/login.vue
  13. 26 6
      apps/web-ele/src/adapter/component/index.ts
  14. 27 6
      apps/web-naive/src/adapter/component/index.ts
  15. 15 7
      docs/src/components/common-ui/vben-drawer.md
  16. 30 23
      docs/src/components/common-ui/vben-form.md
  17. 2 1
      docs/src/components/common-ui/vben-modal.md
  18. 26 10
      docs/src/components/common-ui/vben-vxe-table.md
  19. 1 1
      docs/src/guide/essentials/build.md
  20. 1 1
      docs/src/guide/introduction/thin.md
  21. 1 1
      package.json
  22. 3 0
      packages/@core/base/icons/src/lucide.ts
  23. 51 3
      packages/@core/ui-kit/form-ui/src/form-api.ts
  24. 42 35
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  25. 3 0
      packages/@core/ui-kit/form-ui/src/use-form-context.ts
  26. 9 2
      packages/@core/ui-kit/form-ui/src/vben-use-form.vue
  27. 9 3
      packages/@core/ui-kit/layout-ui/src/vben-layout.vue
  28. 24 1
      packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
  29. 6 2
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
  30. 22 6
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
  31. 8 0
      packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
  32. 5 2
      packages/@core/ui-kit/popup-ui/src/modal/modal.vue
  33. 15 1
      packages/@core/ui-kit/shadcn-ui/src/components/avatar/avatar.vue
  34. 19 2
      packages/@core/ui-kit/shadcn-ui/src/components/breadcrumb/breadcrumb-view.vue
  35. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/checkbox/checkbox.vue
  36. 2 1
      packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
  37. 6 4
      packages/@core/ui-kit/shadcn-ui/src/ui/checkbox/Checkbox.vue
  38. 3 1
      packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
  39. 1 0
      packages/@core/ui-kit/shadcn-ui/src/ui/index.ts
  40. 2 0
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts
  41. 301 0
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue
  42. 29 0
      packages/effects/access/src/accessible.ts
  43. 2 0
      packages/effects/common-ui/src/components/index.ts
  44. 1 1
      packages/effects/layouts/src/authentication/authentication.vue
  45. 24 3
      packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
  46. 3 1
      packages/locales/src/langs/en-US/common.json
  47. 4 1
      packages/locales/src/langs/en-US/ui.json
  48. 3 1
      packages/locales/src/langs/zh-CN/common.json
  49. 4 1
      packages/locales/src/langs/zh-CN/ui.json
  50. 27 6
      playground/src/adapter/component/index.ts
  51. 36 5
      playground/src/adapter/vxe-table.ts
  52. 1 0
      playground/src/api/index.ts
  53. 3 0
      playground/src/api/system/index.ts
  54. 158 0
      playground/src/api/system/menu.ts
  55. 55 0
      playground/src/api/system/role.ts
  56. 52 0
      playground/src/locales/langs/en-US/system.json
  57. 54 0
      playground/src/locales/langs/zh-CN/system.json
  58. 11 1
      playground/src/router/routes/index.ts
  59. 8 7
      playground/src/router/routes/modules/demos.ts
  60. 2 0
      playground/src/router/routes/modules/examples.ts
  61. 18 0
      playground/src/router/routes/modules/system.ts
  62. 19 3
      playground/src/views/_core/authentication/login.vue
  63. 21 1
      playground/src/views/demos/features/hide-menu-children/children.vue
  64. 8 2
      playground/src/views/demos/features/hide-menu-children/parent.vue
  65. 9 2
      playground/src/views/examples/drawer/base-demo.vue
  66. 6 1
      playground/src/views/examples/drawer/in-content-demo.vue
  67. 1 0
      playground/src/views/examples/drawer/index.vue
  68. 10 1
      playground/src/views/examples/form/api.vue
  69. 9 1
      playground/src/views/examples/modal/base-demo.vue
  70. 6 1
      playground/src/views/examples/modal/in-content-demo.vue
  71. 2 0
      playground/src/views/examples/modal/index.vue
  72. 109 0
      playground/src/views/system/menu/data.ts
  73. 162 0
      playground/src/views/system/menu/list.vue
  74. 521 0
      playground/src/views/system/menu/modules/form.vue
  75. 127 0
      playground/src/views/system/role/data.ts
  76. 164 0
      playground/src/views/system/role/list.vue
  77. 139 0
      playground/src/views/system/role/modules/form.vue
  78. 195 273
      pnpm-lock.yaml
  79. 48 48
      pnpm-workspace.yaml

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

+ 83 - 0
apps/backend-mock/api/system/role/list.ts

@@ -0,0 +1,83 @@
+import { faker } from '@faker-js/faker';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data';
+import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
+
+const formatterCN = new Intl.DateTimeFormat('zh-CN', {
+  timeZone: 'Asia/Shanghai',
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+  second: '2-digit',
+});
+
+const menuIds = getMenuIds(MOCK_MENU_LIST);
+
+function generateMockDataList(count: number) {
+  const dataList = [];
+
+  for (let i = 0; i < count; i++) {
+    const dataItem: Record<string, any> = {
+      id: faker.string.uuid(),
+      name: faker.commerce.product(),
+      status: faker.helpers.arrayElement([0, 1]),
+      createTime: formatterCN.format(
+        faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
+      ),
+      permissions: faker.helpers.arrayElements(menuIds),
+      remark: faker.lorem.sentence(),
+    };
+
+    dataList.push(dataItem);
+  }
+
+  return dataList;
+}
+
+const mockData = generateMockDataList(100);
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+
+  const {
+    page = 1,
+    pageSize = 20,
+    name,
+    id,
+    remark,
+    startTime,
+    endTime,
+    status,
+  } = getQuery(event);
+  let listData = structuredClone(mockData);
+  if (name) {
+    listData = listData.filter((item) =>
+      item.name.toLowerCase().includes(String(name).toLowerCase()),
+    );
+  }
+  if (id) {
+    listData = listData.filter((item) =>
+      item.id.toLowerCase().includes(String(id).toLowerCase()),
+    );
+  }
+  if (remark) {
+    listData = listData.filter((item) =>
+      item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
+    );
+  }
+  if (startTime) {
+    listData = listData.filter((item) => item.createTime >= startTime);
+  }
+  if (endTime) {
+    listData = listData.filter((item) => item.createTime <= endTime);
+  }
+  if (['0', '1'].includes(status as string)) {
+    listData = listData.filter((item) => item.status === Number(status));
+  }
+  return usePageResponseSuccess(page as string, pageSize as string, listData);
+});

+ 1 - 1
apps/backend-mock/api/table/list.ts

@@ -1,6 +1,6 @@
 import { faker } from '@faker-js/faker';
 import { verifyAccessToken } from '~/utils/jwt-utils';
-import { unAuthorizedResponse } from '~/utils/response';
+import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
 
 function generateMockDataList(count: number) {
   const dataList = [];

+ 1 - 1
apps/backend-mock/middleware/1.api.ts

@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
     ['DELETE', 'PATCH', 'POST', 'PUT'].includes(event.method) &&
     event.path.startsWith('/api/system/')
   ) {
-    await sleep(Math.floor(Math.random() * 1000));
+    await sleep(Math.floor(Math.random() * 2000));
     return forbiddenResponse(event, '演示环境,禁止修改');
   }
 });

+ 1 - 1
apps/backend-mock/utils/cookie-utils.ts

@@ -14,7 +14,7 @@ export function setRefreshTokenCookie(
 ) {
   setCookie(event, 'jwt', refreshToken, {
     httpOnly: true,
-    maxAge: 24 * 60 * 60 * 1000,
+    maxAge: 24 * 60 * 60, // unit: seconds
     sameSite: 'none',
     secure: true,
   });

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

@@ -185,3 +185,206 @@ 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',
+  },
+];
+
+export function getMenuIds(menus: any[]) {
+  const ids: number[] = [];
+  menus.forEach((item) => {
+    ids.push(item.id);
+    if (item.children && item.children.length > 0) {
+      ids.push(...getMenuIds(item.children));
+    }
+  });
+  return ids;
+}

+ 27 - 6
apps/web-antd/src/adapter/component/index.ts

@@ -3,11 +3,12 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
-import type { Component, SetupContext } from 'vue';
+import type { Component } from 'vue';
 
 import type { BaseFormComponentType } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
 
-import { h } from 'vue';
+import { defineComponent, getCurrentInstance, h, ref } from 'vue';
 
 import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -41,10 +42,30 @@ const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
 ) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
+  return defineComponent({
+    inheritAttrs: false,
+    name: component.name,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 27 - 6
apps/web-baicai/src/adapter/component/index.ts

@@ -3,13 +3,14 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
-import type { Component, SetupContext } from 'vue';
+import type { Component } from 'vue';
 
 import type { BaseFormComponentType } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
 
 import type { CustomComponentType } from '#/components/form/types';
 
-import { h } from 'vue';
+import { defineComponent, getCurrentInstance, h, ref } from 'vue';
 
 import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -45,10 +46,30 @@ const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
 ) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
+  return defineComponent({
+    inheritAttrs: false,
+    name: component.name,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 30 - 2
apps/web-baicai/src/adapter/vxe-table.ts

@@ -10,7 +10,7 @@ import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
 import { isFunction, isString } from '@vben/utils';
 
 import { objectOmit } from '@vueuse/core';
-import { Button, Image, Modal, Popconfirm, Tag } from 'ant-design-vue';
+import { Button, Image, Modal, Popconfirm, Switch, Tag } from 'ant-design-vue';
 
 import { TableAction } from '#/components/table-action';
 import { $t } from '#/locales';
@@ -91,13 +91,41 @@ setupVbenVxeTable({
           Tag,
           {
             ...props,
-            ...objectOmit(tagItem, ['label']),
+            ...objectOmit(tagItem ?? {}, ['label']),
           },
           { default: () => tagItem?.label ?? value },
         );
       },
     });
 
+    vxeUI.renderer.add('CellSwitch', {
+      renderTableDefault({ attrs, props }, { column, row }) {
+        const loadingKey = `__loading_${column.field}`;
+        const finallyProps = {
+          checkedChildren: $t('common.enabled'),
+          checkedValue: 1,
+          unCheckedChildren: $t('common.disabled'),
+          unCheckedValue: 0,
+          ...props,
+          checked: row[column.field],
+          loading: row[loadingKey] ?? false,
+          'onUpdate:checked': onChange,
+        };
+        async function onChange(newVal: any) {
+          row[loadingKey] = true;
+          try {
+            const result = await attrs?.beforeChange?.(newVal, row);
+            if (result !== false) {
+              row[column.field] = newVal;
+            }
+          } finally {
+            row[loadingKey] = false;
+          }
+        }
+        return h(Switch, finallyProps);
+      },
+    });
+
     /**
      * 注册表格的操作按钮渲染器
      */

+ 19 - 3
apps/web-baicai/src/views/_core/authentication/login.vue

@@ -1,7 +1,8 @@
 <script lang="ts" setup>
-import type { VbenFormSchema } from '@vben/common-ui';
+import type { SliderCaptcha, VbenFormSchema } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
 
-import { computed } from 'vue';
+import { computed, useTemplateRef } from 'vue';
 
 import { AuthenticationLogin, z } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -41,10 +42,25 @@ const formSchema = computed((): VbenFormSchema[] => {
     // },
   ];
 });
+const loginRef =
+  useTemplateRef<InstanceType<typeof AuthenticationLogin>>('loginRef');
+
+async function onSubmit(params: Recordable<any>) {
+  authStore.authLogin(params).catch(() => {
+    // 登陆失败,刷新验证码的演示
+
+    // 使用表单API获取验证码组件实例,并调用其resume方法来重置验证码
+    loginRef.value
+      ?.getFormApi()
+      ?.getFieldComponentRef<InstanceType<typeof SliderCaptcha>>('captcha')
+      ?.resume();
+  });
+}
 </script>
 
 <template>
   <AuthenticationLogin
+    ref="loginRef"
     :form-schema="formSchema"
     :loading="authStore.loginLoading"
     :show-code-login="false"
@@ -53,6 +69,6 @@ const formSchema = computed((): VbenFormSchema[] => {
     :show-register="false"
     :show-remember-me="false"
     :show-third-party-login="false"
-    @submit="authStore.authLogin"
+    @submit="onSubmit"
   />
 </template>

+ 26 - 6
apps/web-ele/src/adapter/component/index.ts

@@ -3,12 +3,12 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
-import type { Component, SetupContext } from 'vue';
+import type { Component } from 'vue';
 
 import type { BaseFormComponentType } from '@vben/common-ui';
 import type { Recordable } from '@vben/types';
 
-import { h } from 'vue';
+import { defineComponent, getCurrentInstance, h, ref } from 'vue';
 
 import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -38,10 +38,30 @@ const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
 ) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
+  return defineComponent({
+    inheritAttrs: false,
+    name: component.name,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 27 - 6
apps/web-naive/src/adapter/component/index.ts

@@ -3,11 +3,12 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
-import type { Component, SetupContext } from 'vue';
+import type { Component } from 'vue';
 
 import type { BaseFormComponentType } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
 
-import { h } from 'vue';
+import { defineComponent, getCurrentInstance, h, ref } from 'vue';
 
 import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -37,10 +38,30 @@ const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
 ) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
+  return defineComponent({
+    inheritAttrs: false,
+    name: component.name,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 15 - 7
docs/src/components/common-ui/vben-drawer.md

@@ -137,11 +137,19 @@ const [Drawer, drawerApi] = useVbenDrawer({
 
 ### drawerApi
 
-| 方法 | 描述 | 类型 |
-| --- | --- | --- |
+| 方法 | 描述 | 类型 | 版本限制 |
+| --- | --- | --- | --- |
 | setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial<ModalState>)\| Partial<ModalState>)=>drawerApi` |
-| open | 打开弹窗 | `()=>void` |
-| close | 关闭弹窗 | `()=>void` |
-| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` |
-| getData | 获取共享数据 | `<T>()=>T` |
-| useStore | 获取可响应式状态 | - |
+| open | 打开弹窗 | `()=>void` | --- |
+| close | 关闭弹窗 | `()=>void` | --- |
+| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` | --- |
+| getData | 获取共享数据 | `<T>()=>T` | --- |
+| useStore | 获取可响应式状态 | - | --- |
+| lock | 将抽屉标记为提交中,锁定当前状态 | `(isLock:boolean)=>drawerApi` | >5.5.3 |
+| unlock | lock方法的反操作,解除抽屉的锁定状态,也是lock(false)的别名 | `()=>drawerApi` | >5.5.3 |
+
+::: info lock
+
+`lock`方法用于锁定抽屉的状态,一般用于提交数据的过程中防止用户重复提交或者抽屉被意外关闭、表单数据被改变等等。当处于锁定状态时,抽屉的确认按钮会变为loading状态,同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭抽屉、开启抽屉的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的抽屉时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
+
+:::

+ 30 - 23
docs/src/components/common-ui/vben-form.md

@@ -279,22 +279,24 @@ const [Form, formApi] = useVbenForm({
 
 useVbenForm 返回的第二个参数,是一个对象,包含了一些表单的方法。
 
-| 方法名 | 描述 | 类型 |
-| --- | --- | --- |
-| submitForm | 提交表单 | `(e:Event)=>Promise<Record<string,any>>` |
-| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise<Record<string,any>>` |
-| resetForm | 重置表单 | `()=>Promise<void>` |
-| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record<string, any>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` |
-| getValues | 获取表单值 | `(fields:Record<string, any>,shouldValidate: boolean = false)=>Promise<void>` |
-| validate | 表单校验 | `()=>Promise<void>` |
-| validateField | 校验指定字段 | `(fieldName: string)=>Promise<ValidationResult<unknown>>` |
-| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` |
-| resetValidate | 重置表单校验 | `()=>Promise<void>` |
-| updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` |
-| setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise<void>` |
-| setState | 设置组件状态(props) | `(stateOrFn:\| ((prev: VbenFormProps) => Partial<VbenFormProps>)\| Partial<VbenFormProps>)=>Promise<void>` |
-| getState | 获取组件状态(props) | `()=>Promise<VbenFormProps>` |
-| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - |
+| 方法名 | 描述 | 类型 | 版本号 |
+| --- | --- | --- | --- |
+| submitForm | 提交表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
+| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
+| resetForm | 重置表单 | `()=>Promise<void>` | - |
+| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record<string, any>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
+| getValues | 获取表单值 | `(fields:Record<string, any>,shouldValidate: boolean = false)=>Promise<void>` | - |
+| validate | 表单校验 | `()=>Promise<void>` | - |
+| validateField | 校验指定字段 | `(fieldName: string)=>Promise<ValidationResult<unknown>>` | - |
+| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - |
+| resetValidate | 重置表单校验 | `()=>Promise<void>` | - |
+| updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` | - |
+| setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise<void>` | - |
+| setState | 设置组件状态(props) | `(stateOrFn:\| ((prev: VbenFormProps) => Partial<VbenFormProps>)\| Partial<VbenFormProps>)=>Promise<void>` | - |
+| getState | 获取组件状态(props) | `()=>Promise<VbenFormProps>` | - |
+| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - | - |
+| getFieldComponentRef | 获取指定字段的组件实例 | `<T=unknown>(fieldName: string)=>T` | >5.5.3 |
+| getFocusedField | 获取当前已获得焦点的字段 | `()=>string\|undefined` | >5.5.3 |
 
 ## Props
 
@@ -518,20 +520,25 @@ import { z } from '#/adapter/form';
 
 // 可选(可以是undefined),并且携带默认值。注意zod的optional不包括空字符串''
 {
-   rules: z.string().default('默认值').optional(),
+  rules: z.string().default('默认值').optional();
+}
+
+// 可以是空字符串、undefined或者一个邮箱地址(两种不同的用法)
+{
+  rules: z.union([z.string().email().optional(), z.literal('')]);
 }
 
-// 可以是空字符串、undefined或者一个邮箱地址
 {
-  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',
+    });
 }
 ```
 

+ 2 - 1
docs/src/components/common-ui/vben-modal.md

@@ -155,9 +155,10 @@ const [Modal, modalApi] = useVbenModal({
 | getData | 获取共享数据 | `<T>()=>T` | - |
 | useStore | 获取可响应式状态 | - | - |
 | lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | >5.5.2 |
+| unlock | lock方法的反操作,解除弹窗的锁定状态,也是lock(false)的别名 | `()=>modalApi` | >5.5.3 |
 
 ::: info lock
 
-`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用确认按钮、隐藏关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。
+`lock`方法用于锁定当前弹窗的状态,一般用于提交数据的过程中防止用户重复提交或者弹窗被意外关闭、表单数据被改变等等。当处于锁定状态时,弹窗的确认按钮会变为loading状态,同时禁用取消按钮和关闭按钮、禁止ESC或者点击遮罩等方式关闭弹窗、开启弹窗的spinner动画以遮挡弹窗内容。调用`close`方法关闭处于锁定状态的弹窗时,会自动解锁。要主动解除这种状态,可以调用`unlock`方法或者再次调用lock方法并传入false参数。
 
 :::

+ 26 - 10
docs/src/components/common-ui/vben-vxe-table.md

@@ -165,7 +165,7 @@ vxeUI.renderer.add('CellLink', {
 
 **表单搜索** 部分采用了`Vben Form 表单`,参考 [Vben Form 表单文档](/components/common-ui/vben-form)。
 
-当启用了表单搜索时,可以在toolbarConfig中配置`search`为`true`来让表格在工具栏区域显示一个搜索表单控制按钮。
+当启用了表单搜索时,可以在toolbarConfig中配置`search`为`true`来让表格在工具栏区域显示一个搜索表单控制按钮。表格的所有以`form-`开头的命名插槽都会被传递给搜索表单。
 
 <DemoPreview dir="demos/vben-vxe-table/form" />
 
@@ -231,12 +231,28 @@ useVbenVxeGrid 返回的第二个参数,是一个对象,包含了一些表
 
 所有属性都可以传入 `useVbenVxeGrid` 的第一个参数中。
 
-| 属性名         | 描述               | 类型                |
-| -------------- | ------------------ | ------------------- |
-| tableTitle     | 表格标题           | `string`            |
-| tableTitleHelp | 表格标题帮助信息   | `string`            |
-| gridClass      | grid组件的class    | `string`            |
-| gridOptions    | grid组件的参数     | `VxeTableGridProps` |
-| gridEvents     | grid组件的触发的⌚️ | `VxeGridListeners`  |
-| formOptions    | 表单参数           | `VbenFormProps`     |
-| showSearchForm | 是否显示搜索表单   | `boolean`           |
+| 属性名         | 描述                 | 类型                |
+| -------------- | -------------------- | ------------------- |
+| tableTitle     | 表格标题             | `string`            |
+| tableTitleHelp | 表格标题帮助信息     | `string`            |
+| gridClass      | grid组件的class      | `string`            |
+| gridOptions    | grid组件的参数       | `VxeTableGridProps` |
+| gridEvents     | grid组件的触发的事件 | `VxeGridListeners`  |
+| formOptions    | 表单参数             | `VbenFormProps`     |
+| showSearchForm | 是否显示搜索表单     | `boolean`           |
+
+## Slots
+
+大部分插槽的说明请参考 [vxe-table 官方文档](https://vxetable.cn/v4/#/grid/api),但工具栏部分由于做了一些定制封装,需使用以下插槽定制表格的工具栏:
+
+| 插槽名          | 描述                                         |
+| --------------- | -------------------------------------------- |
+| toolbar-actions | 工具栏左侧部分(表格标题附近)               |
+| toolbar-tools   | 工具栏右侧部分(vxeTable原生工具按钮的左侧) |
+| table-title     | 表格标题插槽                                 |
+
+::: info 搜索表单的插槽
+
+对于使用了搜索表单的表格来说,所有以`form-`开头的命名插槽都会传递给表单。
+
+:::

+ 1 - 1
docs/src/guide/essentials/build.md

@@ -47,7 +47,7 @@ cd apps/web-antd/dist
 # 本地预览,默认端口8080
 live-server
 # 指定端口
-live-server --port 9000
+live-server --port=9000
 ```
 
 ## 压缩

+ 1 - 1
docs/src/guide/introduction/thin.md

@@ -72,7 +72,7 @@ pnpm install
 
 ## 其他
 
-如果你想更进一步精简,你可以删除参考下文件或者文件夹的作用,判断自己是否需要,不需要删除即可:
+如果你想更进一步精简,你可以删除参考下文件或者文件夹的作用,判断自己是否需要,不需要删除即可:
 
 - `.changeset` 文件夹用于管理版本变更
 - `.github` 文件夹用于存放 GitHub 的配置文件

+ 1 - 1
package.json

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

+ 3 - 0
packages/@core/base/icons/src/lucide.ts

@@ -55,6 +55,9 @@ export {
   SearchX,
   Settings,
   Shrink,
+  Square,
+  SquareCheckBig,
+  SquareMinus,
   Sun,
   SunMoon,
   SwatchBook,

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

@@ -5,6 +5,8 @@ import type {
   ValidationOptions,
 } from 'vee-validate';
 
+import type { ComponentPublicInstance } from 'vue';
+
 import type { Recordable } from '@vben-core/typings';
 
 import type { FormActions, FormSchema, VbenFormProps } from './types';
@@ -56,6 +58,11 @@ export class FormApi {
 
   public store: Store<VbenFormProps>;
 
+  /**
+   * 组件实例映射
+   */
+  private componentRefMap: Map<string, unknown> = new Map();
+
   // 最后一次点击提交时的表单值
   private latestSubmissionValues: null | Recordable<any> = null;
 
@@ -85,6 +92,46 @@ export class FormApi {
     bindMethods(this);
   }
 
+  /**
+   * 获取字段组件实例
+   * @param fieldName 字段名
+   * @returns 组件实例
+   */
+  getFieldComponentRef<T = ComponentPublicInstance>(
+    fieldName: string,
+  ): T | undefined {
+    return this.componentRefMap.has(fieldName)
+      ? (this.componentRefMap.get(fieldName) as T)
+      : undefined;
+  }
+
+  /**
+   * 获取当前聚焦的字段,如果没有聚焦的字段则返回undefined
+   */
+  getFocusedField() {
+    for (const fieldName of this.componentRefMap.keys()) {
+      const ref = this.getFieldComponentRef(fieldName);
+      if (ref) {
+        let el: HTMLElement | null = null;
+        if (ref instanceof HTMLElement) {
+          el = ref;
+        } else if (ref.$el instanceof HTMLElement) {
+          el = ref.$el;
+        }
+        if (!el) {
+          continue;
+        }
+        if (
+          el === document.activeElement ||
+          el.contains(document.activeElement)
+        ) {
+          return fieldName;
+        }
+      }
+    }
+    return undefined;
+  }
+
   getLatestSubmissionValues() {
     return this.latestSubmissionValues || {};
   }
@@ -93,9 +140,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) {
@@ -143,13 +190,14 @@ export class FormApi {
     return proxy;
   }
 
-  mount(formActions: FormActions) {
+  mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
     if (!this.isMounted) {
       Object.assign(this.form, formActions);
       this.stateHandler.setConditionTrue();
       this.setLatestSubmissionValues({
         ...toRaw(this.handleRangeTimeValue(this.form.values)),
       });
+      this.componentRefMap = componentRefMap;
       this.isMounted = true;
     }
   }

+ 42 - 35
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

@@ -3,7 +3,7 @@ import type { ZodType } from 'zod';
 
 import type { FormSchema, MaybeComponentProps } from '../types';
 
-import { computed, nextTick, useTemplateRef, watch } from 'vue';
+import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
 
 import {
   FormControl,
@@ -18,6 +18,7 @@ import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
 import { toTypedSchema } from '@vee-validate/zod';
 import { useFieldError, useFormValues } from 'vee-validate';
 
+import { injectComponentRefMap } from '../use-form-context';
 import { injectRenderFormProps, useFormContext } from './context';
 import useDependencies from './dependencies';
 import FormLabel from './form-label.vue';
@@ -267,6 +268,15 @@ function autofocus() {
     fieldComponentRef.value?.focus?.();
   }
 }
+const componentRefMap = injectComponentRefMap();
+watch(fieldComponentRef, (componentRef) => {
+  componentRefMap?.set(fieldName, componentRef);
+});
+onUnmounted(() => {
+  if (componentRefMap?.has(fieldName)) {
+    componentRefMap.delete(fieldName);
+  }
+});
 </script>
 
 <template>
@@ -310,44 +320,41 @@ function autofocus() {
           <VbenRenderContent :content="label" />
         </template>
       </FormLabel>
-      <div class="w-full overflow-hidden">
+      <div class="flex-auto overflow-hidden">
         <div :class="cn('relative flex w-full items-center', wrapperClass)">
-          <div class="flex-auto overflow-hidden p-[2px]">
-            <FormControl :class="cn(controlClass)">
-              <slot
-                v-bind="{
-                  ...slotProps,
-                  ...createComponentProps(slotProps),
-                  disabled: shouldDisabled,
-                  isInValid,
+          <FormControl :class="cn(controlClass)">
+            <slot
+              v-bind="{
+                ...slotProps,
+                ...createComponentProps(slotProps),
+                disabled: shouldDisabled,
+                isInValid,
+              }"
+            >
+              <component
+                :is="FieldComponent"
+                ref="fieldComponentRef"
+                :class="{
+                  'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
+                    isInValid,
                 }"
+                v-bind="createComponentProps(slotProps)"
+                :disabled="shouldDisabled"
               >
-                <component
-                  :is="FieldComponent"
-                  ref="fieldComponentRef"
-                  :class="{
-                    'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
-                      isInValid,
-                  }"
-                  v-bind="createComponentProps(slotProps)"
-                  :disabled="shouldDisabled"
+                <template
+                  v-for="name in renderContentKey"
+                  :key="name"
+                  #[name]="renderSlotProps"
                 >
-                  <template
-                    v-for="name in renderContentKey"
-                    :key="name"
-                    #[name]="renderSlotProps"
-                  >
-                    <VbenRenderContent
-                      :content="customContentRender[name]"
-                      v-bind="{ ...renderSlotProps, formContext: slotProps }"
-                    />
-                  </template>
-                  <!-- <slot></slot> -->
-                </component>
-              </slot>
-            </FormControl>
-          </div>
-
+                  <VbenRenderContent
+                    :content="customContentRender[name]"
+                    v-bind="{ ...renderSlotProps, formContext: slotProps }"
+                  />
+                </template>
+                <!-- <slot></slot> -->
+              </component>
+            </slot>
+          </FormControl>
           <!-- 自定义后缀 -->
           <div v-if="suffix" class="ml-1">
             <VbenRenderContent :content="suffix" />

+ 3 - 0
packages/@core/ui-kit/form-ui/src/use-form-context.ts

@@ -20,6 +20,9 @@ export const [injectFormProps, provideFormProps] =
     'VbenFormProps',
   );
 
+export const [injectComponentRefMap, provideComponentRefMap] =
+  createContext<Map<string, unknown>>('ComponentRefMap');
+
 export function useFormInitial(
   props: ComputedRef<VbenFormProps> | VbenFormProps,
 ) {

+ 9 - 2
packages/@core/ui-kit/form-ui/src/vben-use-form.vue

@@ -17,7 +17,11 @@ import {
   DEFAULT_FORM_COMMON_CONFIG,
 } from './config';
 import { Form } from './form-render';
-import { provideFormProps, useFormInitial } from './use-form-context';
+import {
+  provideComponentRefMap,
+  provideFormProps,
+  useFormInitial,
+} from './use-form-context';
 // 通过 extends 会导致热更新卡死,所以重复写了一遍
 interface Props extends VbenFormProps {
   formApi: ExtendedFormApi;
@@ -29,11 +33,14 @@ const state = props.formApi?.useStore?.();
 
 const forward = useForwardPriorityValues(props, state);
 
+const componentRefMap = new Map<string, unknown>();
+
 const { delegatedSlots, form } = useFormInitial(forward);
 
 provideFormProps([forward, form]);
+provideComponentRefMap(componentRefMap);
 
-props.formApi?.mount?.(form);
+props.formApi?.mount?.(form, componentRefMap);
 
 const handleUpdateCollapsed = (value: boolean) => {
   props.formApi?.setState({ collapsed: !!value });

+ 9 - 3
packages/@core/ui-kit/layout-ui/src/vben-layout.vue

@@ -62,10 +62,16 @@ const props = withDefaults(defineProps<Props>(), {
 });
 
 const emit = defineEmits<{ sideMouseLeave: []; toggleSidebar: [] }>();
-const sidebarCollapse = defineModel<boolean>('sidebarCollapse');
+const sidebarCollapse = defineModel<boolean>('sidebarCollapse', {
+  default: false,
+});
 const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
-const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse');
-const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover');
+const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse', {
+  default: false,
+});
+const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
+  default: false,
+});
 const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
 
 // side是否处于hover状态展开菜单中

+ 24 - 1
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts

@@ -38,6 +38,7 @@ export class DrawerApi {
     const defaultState: DrawerState = {
       class: '',
       closable: true,
+      closeIconPlacement: 'right',
       closeOnClickModal: true,
       closeOnPressEscape: true,
       confirmLoading: false,
@@ -51,6 +52,7 @@ export class DrawerApi {
       placement: 'right',
       showCancelButton: true,
       showConfirmButton: true,
+      submitting: false,
       title: '',
     };
 
@@ -91,7 +93,11 @@ export class DrawerApi {
     // 如果 onBeforeClose 返回 false,则不关闭弹窗
     const allowClose = this.api.onBeforeClose?.() ?? true;
     if (allowClose) {
-      this.store.setState((prev) => ({ ...prev, isOpen: false }));
+      this.store.setState((prev) => ({
+        ...prev,
+        isOpen: false,
+        submitting: false,
+      }));
     }
   }
 
@@ -99,6 +105,15 @@ export class DrawerApi {
     return (this.sharedData?.payload ?? {}) as T;
   }
 
+  /**
+   * 锁定抽屉状态(用于提交过程中的等待状态)
+   * @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
+   * @param isLocked 是否锁定
+   */
+  lock(isLocked: boolean = true) {
+    return this.setState({ submitting: isLocked });
+  }
+
   /**
    * 取消操作
    */
@@ -156,4 +171,12 @@ export class DrawerApi {
     }
     return this;
   }
+
+  /**
+   * 解除抽屉的锁定状态
+   * @description 解除由lock方法设置的锁定状态,是lock(false)的别名
+   */
+  unlock() {
+    return this.lock(false);
+  }
 }

+ 6 - 2
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts

@@ -75,12 +75,12 @@ export interface DrawerProps {
    * @default false
    */
   loading?: boolean;
-
   /**
    * 是否显示遮罩
    * @default true
    */
   modal?: boolean;
+
   /**
    * 是否自动聚焦
    */
@@ -89,12 +89,12 @@ export interface DrawerProps {
    * 弹窗遮罩模糊效果
    */
   overlayBlur?: number;
-
   /**
    * 抽屉位置
    * @default right
    */
   placement?: DrawerPlacement;
+
   /**
    * 是否显示取消按钮
    * @default true
@@ -105,6 +105,10 @@ export interface DrawerProps {
    * @default true
    */
   showConfirmButton?: boolean;
+  /**
+   * 提交中(锁定抽屉状态)
+   */
+  submitting?: boolean;
   /**
    * 弹窗标题
    */

+ 22 - 6
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue

@@ -36,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
   appendToMain: false,
   closeIconPlacement: 'right',
   drawerApi: undefined,
+  submitting: false,
   zIndex: 1000,
 });
 
@@ -55,6 +56,7 @@ const {
   cancelText,
   class: drawerClass,
   closable,
+  closeIconPlacement,
   closeOnClickModal,
   closeOnPressEscape,
   confirmLoading,
@@ -72,6 +74,7 @@ const {
   placement,
   showCancelButton,
   showConfirmButton,
+  submitting,
   title,
   titleTooltip,
   zIndex,
@@ -90,12 +93,12 @@ watch(
 );
 
 function interactOutside(e: Event) {
-  if (!closeOnClickModal.value) {
+  if (!closeOnClickModal.value || submitting.value) {
     e.preventDefault();
   }
 }
 function escapeKeyDown(e: KeyboardEvent) {
-  if (!closeOnPressEscape.value) {
+  if (!closeOnPressEscape.value || submitting.value) {
     e.preventDefault();
   }
 }
@@ -103,7 +106,11 @@ function escapeKeyDown(e: KeyboardEvent) {
 function pointerDownOutside(e: Event) {
   const target = e.target as HTMLElement;
   const dismissableDrawer = target?.dataset.dismissableDrawer;
-  if (!closeOnClickModal.value || dismissableDrawer !== id) {
+  if (
+    submitting.value ||
+    !closeOnClickModal.value ||
+    dismissableDrawer !== id
+  ) {
     e.preventDefault();
   }
 }
@@ -120,7 +127,9 @@ function handleFocusOutside(e: Event) {
 }
 
 const getAppendTo = computed(() => {
-  return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined;
+  return appendToMain.value
+    ? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
+    : undefined;
 });
 </script>
 <template>
@@ -168,6 +177,7 @@ const getAppendTo = computed(() => {
           <SheetClose
             v-if="closable && closeIconPlacement === 'left'"
             as-child
+            :disabled="submitting"
             class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
           >
             <slot name="close-icon">
@@ -208,6 +218,7 @@ const getAppendTo = computed(() => {
           <SheetClose
             v-if="closable && closeIconPlacement === 'right'"
             as-child
+            :disabled="submitting"
             class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
           >
             <slot name="close-icon">
@@ -232,7 +243,11 @@ const getAppendTo = computed(() => {
           })
         "
       >
-        <VbenLoading v-if="showLoading" class="size-full" spinning />
+        <VbenLoading
+          v-if="showLoading || submitting"
+          class="size-full"
+          spinning
+        />
 
         <slot></slot>
       </div>
@@ -252,6 +267,7 @@ const getAppendTo = computed(() => {
             :is="components.DefaultButton || VbenButton"
             v-if="showCancelButton"
             variant="ghost"
+            :disabled="submitting"
             @click="() => drawerApi?.onCancel()"
           >
             <slot name="cancelText">
@@ -262,7 +278,7 @@ const getAppendTo = computed(() => {
           <component
             :is="components.PrimaryButton || VbenButton"
             v-if="showConfirmButton"
-            :loading="confirmLoading"
+            :loading="confirmLoading || submitting"
             @click="() => drawerApi?.onConfirm()"
           >
             <slot name="confirmText">

+ 8 - 0
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts

@@ -180,4 +180,12 @@ export class ModalApi {
     }
     return this;
   }
+
+  /**
+   * 解除弹窗的锁定状态
+   * @description 解除由lock方法设置的锁定状态,是lock(false)的别名
+   */
+  unlock() {
+    return this.lock(false);
+  }
 }

+ 5 - 2
packages/@core/ui-kit/popup-ui/src/modal/modal.vue

@@ -172,7 +172,9 @@ function handleFocusOutside(e: Event) {
   e.stopPropagation();
 }
 const getAppendTo = computed(() => {
-  return appendToMain.value ? `#${ELEMENT_ID_MAIN_CONTENT}` : undefined;
+  return appendToMain.value
+    ? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
+    : undefined;
 });
 </script>
 <template>
@@ -200,12 +202,13 @@ const getAppendTo = computed(() => {
       "
       :modal="modal"
       :open="state?.isOpen"
-      :show-close="submitting ? false : closable"
+      :show-close="closable"
       :z-index="zIndex"
       :overlay-blur="overlayBlur"
       close-class="top-3"
       @close-auto-focus="handleFocusOutside"
       @closed="() => modalApi?.onClosed()"
+      :close-disabled="submitting"
       @escape-key-down="escapeKeyDown"
       @focus-outside="handleFocusOutside"
       @interact-outside="interactOutside"

+ 15 - 1
packages/@core/ui-kit/shadcn-ui/src/components/avatar/avatar.vue

@@ -16,6 +16,7 @@ interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
   class?: ClassType;
   dot?: boolean;
   dotClass?: ClassType;
+  size?: number;
 }
 
 defineOptions({
@@ -32,10 +33,23 @@ const props = withDefaults(defineProps<Props>(), {
 const text = computed(() => {
   return props.alt.slice(-2).toUpperCase();
 });
+
+const rootStyle = computed(() => {
+  return props.size !== undefined && props.size > 0
+    ? {
+        height: `${props.size}px`,
+        width: `${props.size}px`,
+      }
+    : {};
+});
 </script>
 
 <template>
-  <div :class="props.class" class="relative flex flex-shrink-0 items-center">
+  <div
+    :class="props.class"
+    :style="rootStyle"
+    class="relative flex flex-shrink-0 items-center"
+  >
     <Avatar :class="props.class" class="size-full">
       <AvatarImage :alt="alt" :src="src" />
       <AvatarFallback>{{ text }}</AvatarFallback>

+ 19 - 2
packages/@core/ui-kit/shadcn-ui/src/components/breadcrumb/breadcrumb-view.vue

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

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/src/components/checkbox/checkbox.vue

@@ -7,7 +7,7 @@ import { useForwardPropsEmits } from 'radix-vue';
 
 import { Checkbox } from '../../ui/checkbox';
 
-const props = defineProps<CheckboxRootProps>();
+const props = defineProps<CheckboxRootProps & { indeterminate?: boolean }>();
 
 const emits = defineEmits<CheckboxRootEmits>();
 

+ 2 - 1
packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue

@@ -52,7 +52,8 @@ withDefaults(defineProps<Props>(), {
         v-if="src"
         :alt="text"
         :src="src"
-        class="relative w-8 rounded-none bg-transparent"
+        :size="logoSize"
+        class="relative rounded-none bg-transparent"
       />
       <span
         v-if="!collapsed"

+ 6 - 4
packages/@core/ui-kit/shadcn-ui/src/ui/checkbox/Checkbox.vue

@@ -5,14 +5,16 @@ import { computed } from 'vue';
 
 import { cn } from '@vben-core/shared/utils';
 
-import { Check } from 'lucide-vue-next';
+import { Check, Minus } from 'lucide-vue-next';
 import {
   CheckboxIndicator,
   CheckboxRoot,
   useForwardPropsEmits,
 } from 'radix-vue';
 
-const props = defineProps<CheckboxRootProps & { class?: any }>();
+const props = defineProps<
+  CheckboxRootProps & { class?: any; indeterminate?: boolean }
+>();
 const emits = defineEmits<CheckboxRootEmits>();
 
 const delegatedProps = computed(() => {
@@ -29,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
     v-bind="forwarded"
     :class="
       cn(
-        'focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground border-border peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
+        'focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground border-border peer h-4 w-4 shrink-0 rounded-sm border transition focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
         props.class,
       )
     "
@@ -38,7 +40,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
       class="flex h-full w-full items-center justify-center text-current"
     >
       <slot>
-        <Check class="h-4 w-4" />
+        <component :is="indeterminate ? Minus : Check" class="h-4 w-4" />
       </slot>
     </CheckboxIndicator>
   </CheckboxRoot>

+ 3 - 1
packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue

@@ -23,6 +23,7 @@ const props = withDefaults(
       appendTo?: HTMLElement | string;
       class?: ClassType;
       closeClass?: ClassType;
+      closeDisabled?: boolean;
       modal?: boolean;
       open?: boolean;
       overlayBlur?: number;
@@ -30,7 +31,7 @@ const props = withDefaults(
       zIndex?: number;
     }
   >(),
-  { appendTo: 'body', showClose: true },
+  { appendTo: 'body', closeDisabled: false, showClose: true },
 );
 const emits = defineEmits<
   DialogContentEmits & { close: []; closed: []; opened: [] }
@@ -108,6 +109,7 @@ defineExpose({
 
       <DialogClose
         v-if="showClose"
+        :disabled="closeDisabled"
         :class="
           cn(
             'data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none',

+ 1 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/index.ts

@@ -27,3 +27,4 @@ export * from './textarea';
 export * from './toggle';
 export * from './toggle-group';
 export * from './tooltip';
+export * from './tree';

+ 2 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts

@@ -0,0 +1,2 @@
+export { default as VbenTree } from './tree.vue';
+export type { FlattenedItem } from 'radix-vue';

+ 301 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue

@@ -0,0 +1,301 @@
+<script lang="ts" setup>
+import type { Arrayable } from '@vueuse/core';
+import type { FlattenedItem } from 'radix-vue';
+
+import type { ClassType, Recordable } from '@vben-core/typings';
+
+import { onMounted, ref, watch, watchEffect } from 'vue';
+
+import { ChevronRight, IconifyIcon } from '@vben-core/icons';
+import { cn, get } from '@vben-core/shared/utils';
+
+import { useVModel } from '@vueuse/core';
+import { TreeItem, TreeRoot } from 'radix-vue';
+
+import { Checkbox } from '../checkbox';
+
+interface TreeProps {
+  /** 单选时允许取消已有选项 */
+  allowClear?: boolean;
+  /** 显示边框 */
+  bordered?: boolean;
+  /** 取消父子关联选择 */
+  checkStrictly?: boolean;
+  /** 子级字段名 */
+  childrenField?: string;
+  /** 默认展开的键 */
+  defaultExpandedKeys?: Array<number | string>;
+  /** 默认展开的级别(优先级高于defaultExpandedKeys) */
+  defaultExpandedLevel?: number;
+  /** 默认值 */
+  defaultValue?: Arrayable<number | string>;
+  /** 禁用 */
+  disabled?: boolean;
+  /** 自定义节点类名 */
+  getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
+  iconField?: string;
+  /** label字段 */
+  labelField?: string;
+  /** 当前值 */
+  modelValue?: Arrayable<number | string>;
+  /** 是否多选 */
+  multiple?: boolean;
+  /** 显示由iconField指定的图标 */
+  showIcon?: boolean;
+  /** 启用展开收缩动画 */
+  transition?: boolean;
+  /** 树数据 */
+  treeData: Recordable<any>[];
+  /** 值字段 */
+  valueField?: string;
+}
+const props = withDefaults(defineProps<TreeProps>(), {
+  allowClear: false,
+  bordered: false,
+  checkStrictly: false,
+  defaultExpandedKeys: () => [],
+  disabled: false,
+  expanded: () => [],
+  iconField: 'icon',
+  labelField: 'label',
+  modelValue: () => [],
+  multiple: false,
+  showIcon: true,
+  transition: false,
+  valueField: 'value',
+  childrenField: 'children',
+});
+
+const emits = defineEmits<{
+  expand: [value: FlattenedItem<Recordable<any>>];
+  select: [value: FlattenedItem<Recordable<any>>];
+  'update:modelValue': [value: Arrayable<Recordable<any>>];
+}>();
+
+interface InnerFlattenItem<T = Recordable<any>> {
+  hasChildren: boolean;
+  level: number;
+  value: T;
+}
+
+function flatten<T = Recordable<any>>(
+  items: T[],
+  childrenField: string = 'children',
+  level = 0,
+): InnerFlattenItem<T>[] {
+  const result: InnerFlattenItem<T>[] = [];
+  items.forEach((item) => {
+    const children = get(item, childrenField) as Array<T>;
+    const val = {
+      hasChildren: Array.isArray(children) && children.length > 0,
+      level,
+      value: item,
+    };
+    result.push(val);
+    if (val.hasChildren)
+      result.push(...flatten(children, childrenField, level + 1));
+  });
+  return result;
+}
+
+const flattenData = ref<Array<InnerFlattenItem>>([]);
+const modelValue = useVModel(props, 'modelValue', emits, {
+  deep: true,
+  defaultValue: props.defaultValue ?? [],
+  passive: (props.modelValue === undefined) as false,
+});
+const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []);
+
+const treeValue = ref();
+
+onMounted(() => {
+  watchEffect(() => {
+    flattenData.value = flatten(props.treeData, props.childrenField);
+    updateTreeValue();
+    if (
+      props.defaultExpandedLevel !== undefined &&
+      props.defaultExpandedLevel > 0
+    )
+      expandToLevel(props.defaultExpandedLevel);
+  });
+});
+
+function getItemByValue(value: number | string) {
+  return flattenData.value.find(
+    (item) => get(item.value, props.valueField) === value,
+  )?.value;
+}
+
+function updateTreeValue() {
+  const val = modelValue.value;
+  treeValue.value = Array.isArray(val)
+    ? val.map((v) => getItemByValue(v))
+    : getItemByValue(val);
+}
+
+watch(
+  modelValue,
+  () => {
+    updateTreeValue();
+  },
+  { deep: true, immediate: true },
+);
+
+function updateModelValue(val: Arrayable<Recordable<any>>) {
+  modelValue.value = Array.isArray(val)
+    ? val.map((v) => get(v, props.valueField))
+    : get(val, props.valueField);
+}
+
+function expandToLevel(level: number) {
+  const keys: string[] = [];
+  flattenData.value.forEach((item) => {
+    if (item.level <= level - 1) {
+      keys.push(get(item.value, props.valueField));
+    }
+  });
+  expanded.value = keys;
+}
+
+function collapseNodes(value: Arrayable<number | string>) {
+  const keys = new Set(Array.isArray(value) ? value : [value]);
+  expanded.value = expanded.value.filter((key) => !keys.has(key));
+}
+
+function expandNodes(value: Arrayable<number | string>) {
+  const keys = [...(Array.isArray(value) ? value : [value])];
+  keys.forEach((key) => {
+    if (expanded.value.includes(key)) return;
+    const item = getItemByValue(key);
+    if (item) {
+      expanded.value.push(key);
+    }
+  });
+}
+
+function expandAll() {
+  expanded.value = flattenData.value
+    .filter((item) => item.hasChildren)
+    .map((item) => get(item.value, props.valueField));
+}
+
+function collapseAll() {
+  expanded.value = [];
+}
+
+function onToggle(item: FlattenedItem<Recordable<any>>) {
+  emits('expand', item);
+}
+function onSelect(item: FlattenedItem<Recordable<any>>) {
+  emits('select', item);
+}
+
+defineExpose({
+  collapseAll,
+  collapseNodes,
+  expandAll,
+  expandNodes,
+  expandToLevel,
+  getItemByValue,
+});
+</script>
+<template>
+  <TreeRoot
+    :get-key="(item) => get(item, valueField)"
+    :get-children="(item) => get(item, childrenField)"
+    :items="treeData"
+    :model-value="treeValue"
+    v-model:expanded="expanded as string[]"
+    :default-expanded="defaultExpandedKeys as string[]"
+    :propagate-select="!checkStrictly"
+    :multiple="multiple"
+    :disabled="disabled"
+    :selection-behavior="allowClear || multiple ? 'toggle' : 'replace'"
+    @update:model-value="updateModelValue"
+    v-slot="{ flattenItems }"
+    :class="
+      cn(
+        'text-blackA11 select-none list-none rounded-lg p-2 text-sm font-medium',
+        $attrs.class as unknown as ClassType,
+        bordered ? 'border' : '',
+      )
+    "
+  >
+    <div class="w-full" v-if="$slots.header">
+      <slot name="header"> </slot>
+    </div>
+    <TreeItem
+      v-for="item in flattenItems"
+      v-slot="{
+        isExpanded,
+        isSelected,
+        isIndeterminate,
+        handleSelect,
+        handleToggle,
+      }"
+      :key="item._id"
+      :style="{ 'padding-left': `${item.level - 0.5}rem` }"
+      :class="
+        cn('cursor-pointer', getNodeClass?.(item), {
+          'data-[selected]:bg-accent': !multiple,
+        })
+      "
+      v-bind="item.bind"
+      @select="
+        (event) => {
+          if (event.detail.originalEvent.type === 'click') {
+            // event.preventDefault();
+          }
+          onSelect(item);
+        }
+      "
+      @toggle="
+        (event) => {
+          if (event.detail.originalEvent.type === 'click') {
+            event.preventDefault();
+          }
+          onToggle(item);
+        }
+      "
+      class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
+    >
+      <ChevronRight
+        v-if="item.hasChildren"
+        class="size-4 cursor-pointer transition"
+        :class="{ 'rotate-90': isExpanded }"
+        @click.stop="handleToggle"
+      />
+      <div v-else class="h-4 w-4">
+        <!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
+      </div>
+      <Checkbox
+        v-if="multiple"
+        :checked="isSelected"
+        :indeterminate="isIndeterminate"
+        @click.stop="handleSelect"
+      />
+      <div
+        class="flex items-center gap-1 pl-2"
+        @click="
+          ($event) => {
+            $event.stopPropagation();
+            $event.preventDefault();
+            handleSelect();
+          }
+        "
+      >
+        <slot name="node" v-bind="item">
+          <IconifyIcon
+            class="size-4"
+            v-if="showIcon && get(item.value, iconField)"
+            :icon="get(item.value, iconField)"
+          />
+          {{ get(item.value, labelField) }}
+        </slot>
+      </div>
+    </TreeItem>
+    <div class="w-full" v-if="$slots.footer">
+      <slot name="footer"> </slot>
+    </div>
+  </TreeRoot>
+</template>

+ 29 - 0
packages/effects/access/src/accessible.ts

@@ -1,14 +1,20 @@
+import type { Component, DefineComponent } from 'vue';
+
 import type {
   AccessModeType,
   GenerateMenuAndRoutesOptions,
   RouteRecordRaw,
 } from '@vben/types';
 
+import { defineComponent, h } from 'vue';
+
 import {
   cloneDeep,
   generateMenus,
   generateRoutesByBackend,
   generateRoutesByFrontend,
+  isFunction,
+  isString,
   mapTree,
 } from '@vben/utils';
 
@@ -81,8 +87,31 @@ async function generateRoutes(
   /**
    * 调整路由树,做以下处理:
    * 1. 对未添加redirect的路由添加redirect
+   * 2. 将懒加载的组件名称修改为当前路由的名称(如果启用了keep-alive的话)
    */
   resultRoutes = mapTree(resultRoutes, (route) => {
+    // 重新包装component,使用与路由名称相同的name以支持keep-alive的条件缓存。
+    if (
+      route.meta?.keepAlive &&
+      isFunction(route.component) &&
+      route.name &&
+      isString(route.name)
+    ) {
+      const originalComponent = route.component as () => Promise<{
+        default: Component | DefineComponent;
+      }>;
+      route.component = async () => {
+        const component = await originalComponent();
+        if (!component.default) return component;
+        return defineComponent({
+          name: route.name as string,
+          setup(props, { attrs, slots }) {
+            return () => h(component.default, { ...props, ...attrs }, slots);
+          },
+        });
+      };
+    }
+
     // 如果有redirect或者没有子路由,则直接返回
     if (route.redirect || !route.children || route.children.length === 0) {
       return route;

+ 2 - 0
packages/effects/common-ui/src/components/index.ts

@@ -25,6 +25,8 @@ export {
   VbenPinInput,
   VbenScrollbar,
   VbenSpinner,
+  VbenTree,
 } from '@vben-core/shadcn-ui';
 
+export type { FlattenedItem } from '@vben-core/shadcn-ui';
 export { globalShareState } from '@vben-core/shared/global-state';

+ 1 - 1
packages/effects/layouts/src/authentication/authentication.vue

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

+ 24 - 3
packages/effects/plugins/src/vxe-table/use-vxe-grid.vue

@@ -33,6 +33,7 @@ import { cloneDeep, cn, mergeWithArrayOverride } from '@vben/utils';
 
 import { VbenHelpTooltip, VbenLoading } from '@vben-core/shadcn-ui';
 
+import { VxeButton } from 'vxe-pc-ui';
 import { VxeGrid, VxeUI } from 'vxe-table';
 
 import { extendProxyOptions } from './extends';
@@ -114,7 +115,7 @@ const toolbarOptions = computed(() => {
   const slotTools = slots[TOOLBAR_TOOLS]?.();
   const searchBtn: VxeToolbarPropTypes.ToolConfig = {
     code: 'search',
-    icon: 'vxe-icon--search',
+    icon: 'vxe-icon-search',
     circle: true,
     status: showSearchForm.value ? 'primary' : undefined,
     title: $t('common.search'),
@@ -200,13 +201,17 @@ const options = computed(() => {
 
 function onToolbarToolClick(event: VxeGridDefines.ToolbarToolClickEventParams) {
   if (event.code === 'search') {
-    props.api?.toggleSearchForm?.();
+    onSearchBtnClick();
   }
   (
     gridEvents.value?.toolbarToolClick as VxeGridListeners['toolbarToolClick']
   )?.(event);
 }
 
+function onSearchBtnClick() {
+  props.api?.toggleSearchForm?.();
+}
+
 const events = computed(() => {
   return {
     ...gridEvents.value,
@@ -218,7 +223,11 @@ const delegatedSlots = computed(() => {
   const resultSlots: string[] = [];
 
   for (const key of Object.keys(slots)) {
-    if (!['empty', 'form', 'loading', TOOLBAR_ACTIONS].includes(key)) {
+    if (
+      !['empty', 'form', 'loading', TOOLBAR_ACTIONS, TOOLBAR_TOOLS].includes(
+        key,
+      )
+    ) {
       resultSlots.push(key);
     }
   }
@@ -344,6 +353,18 @@ onUnmounted(() => {
       >
         <slot :name="slotName" v-bind="slotProps"></slot>
       </template>
+      <template #toolbar-tools="slotProps">
+        <slot name="toolbar-tools" v-bind="slotProps"></slot>
+        <VxeButton
+          icon="vxe-icon-search"
+          circle
+          class="ml-2"
+          v-if="gridOptions?.toolbarConfig?.search && !!formOptions"
+          :status="showSearchForm ? 'primary' : undefined"
+          :title="$t('common.search')"
+          @click="onSearchBtnClick"
+        />
+      </template>
 
       <!-- form表单 -->
       <template #form>

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

+ 27 - 6
playground/src/adapter/component/index.ts

@@ -3,11 +3,12 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
-import type { Component, SetupContext } from 'vue';
+import type { Component } from 'vue';
 
 import type { BaseFormComponentType } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
 
-import { h } from 'vue';
+import { defineComponent, getCurrentInstance, h, ref } from 'vue';
 
 import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -41,10 +42,30 @@ const withDefaultPlaceholder = <T extends Component>(
   component: T,
   type: 'input' | 'select',
 ) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
+  return defineComponent({
+    inheritAttrs: false,
+    name: component.name,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 36 - 5
playground/src/adapter/vxe-table.ts

@@ -5,10 +5,10 @@ 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';
+import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue';
 
 import { $t } from '#/locales';
 
@@ -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,13 +87,41 @@ setupVbenVxeTable({
           Tag,
           {
             ...props,
-            ...objectOmit(tagItem, ['label']),
+            ...objectOmit(tagItem ?? {}, ['label']),
           },
           { default: () => tagItem?.label ?? value },
         );
       },
     });
 
+    vxeUI.renderer.add('CellSwitch', {
+      renderTableDefault({ attrs, props }, { column, row }) {
+        const loadingKey = `__loading_${column.field}`;
+        const finallyProps = {
+          checkedChildren: $t('common.enabled'),
+          checkedValue: 1,
+          unCheckedChildren: $t('common.disabled'),
+          unCheckedValue: 0,
+          ...props,
+          checked: row[column.field],
+          loading: row[loadingKey] ?? false,
+          'onUpdate:checked': onChange,
+        };
+        async function onChange(newVal: any) {
+          row[loadingKey] = true;
+          try {
+            const result = await attrs?.beforeChange?.(newVal, row);
+            if (result !== false) {
+              row[column.field] = newVal;
+            }
+          } finally {
+            row[loadingKey] = false;
+          }
+        }
+        return h(Switch, finallyProps);
+      },
+    });
+
     /**
      * 注册表格的操作按钮渲染器
      */
@@ -183,6 +211,9 @@ setupVbenVxeTable({
           return h(
             Popconfirm,
             {
+              getPopupContainer(el) {
+                return el.closest('tbody') || document.body;
+              },
               placement: 'topLeft',
               title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
               ...props,

+ 1 - 0
playground/src/api/index.ts

@@ -1,2 +1,3 @@
 export * from './core';
 export * from './examples';
+export * from './system';

+ 3 - 0
playground/src/api/system/index.ts

@@ -0,0 +1,3 @@
+export * from './dept';
+export * from './menu';
+export * from './role';

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

+ 55 - 0
playground/src/api/system/role.ts

@@ -0,0 +1,55 @@
+import type { Recordable } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+export namespace SystemRoleApi {
+  export interface SystemRole {
+    [key: string]: any;
+    id: string;
+    name: string;
+    permissions: string[];
+    remark?: string;
+    status: 0 | 1;
+  }
+}
+
+/**
+ * 获取角色列表数据
+ */
+async function getRoleList(params: Recordable<any>) {
+  return requestClient.get<Array<SystemRoleApi.SystemRole>>(
+    '/system/role/list',
+    { params },
+  );
+}
+
+/**
+ * 创建角色
+ * @param data 角色数据
+ */
+async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
+  return requestClient.post('/system/role', data);
+}
+
+/**
+ * 更新角色
+ *
+ * @param id 角色 ID
+ * @param data 角色数据
+ */
+async function updateRole(
+  id: string,
+  data: Omit<SystemRoleApi.SystemRole, 'id'>,
+) {
+  return requestClient.put(`/system/role/${id}`, data);
+}
+
+/**
+ * 删除角色
+ * @param id 角色 ID
+ */
+async function deleteRole(id: string) {
+  return requestClient.delete(`/system/role/${id}`);
+}
+
+export { createRole, deleteRole, getRoleList, updateRole };

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

@@ -9,5 +9,57 @@
     "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"
+  },
+  "role": {
+    "title": "Role Management",
+    "list": "Role List",
+    "name": "Role",
+    "roleName": "Role Name",
+    "id": "Role ID",
+    "status": "Status",
+    "remark": "Remark",
+    "createTime": "Creation Time",
+    "operation": "Operation",
+    "permissions": "Permissions",
+    "setPermissions": "Permissions"
   }
 }

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

@@ -1,5 +1,6 @@
 {
   "dept": {
+    "list": "部门列表",
     "createTime": "创建时间",
     "deptName": "部门名称",
     "name": "部门",
@@ -9,5 +10,58 @@
     "status": "状态",
     "title": "部门管理"
   },
+  "menu": {
+    "list": "菜单列表",
+    "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": "菜单"
+  },
+  "role": {
+    "title": "角色管理",
+    "list": "角色列表",
+    "name": "角色",
+    "roleName": "角色名称",
+    "id": "角色ID",
+    "status": "状态",
+    "remark": "备注",
+    "createTime": "创建时间",
+    "operation": "操作",
+    "permissions": "权限",
+    "setPermissions": "授权"
+  },
   "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 };

+ 8 - 7
playground/src/router/routes/modules/demos.ts

@@ -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'),
+                },
               },
             ],
           },
@@ -531,7 +532,7 @@ const routes: RouteRecordRaw[] = [
             children: [
               {
                 name: 'Menu31Demo',
-                path: 'menu3-1',
+                path: '/demos/nested/menu3/menu3-1',
                 component: () => import('#/views/demos/nested/menu-3-1.vue'),
                 meta: {
                   icon: 'ic:round-menu',
@@ -541,7 +542,7 @@ const routes: RouteRecordRaw[] = [
               },
               {
                 name: 'Menu32Demo',
-                path: 'menu3-2',
+                path: '/demos/nested/menu3/menu3-2',
                 meta: {
                   icon: 'ic:round-menu',
                   title: $t('demos.nested.menu3_2'),

+ 2 - 0
playground/src/router/routes/modules/examples.ts

@@ -213,6 +213,7 @@ const routes: RouteRecordRaw[] = [
         component: () => import('#/views/examples/modal/index.vue'),
         meta: {
           icon: 'system-uicons:window-content',
+          keepAlive: true,
           title: $t('examples.modal.title'),
         },
       },
@@ -222,6 +223,7 @@ const routes: RouteRecordRaw[] = [
         component: () => import('#/views/examples/drawer/index.vue'),
         meta: {
           icon: 'iconoir:drawer',
+          keepAlive: true,
           title: $t('examples.drawer.title'),
         },
       },

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

@@ -12,6 +12,24 @@ const routes: RouteRecordRaw[] = [
     name: 'System',
     path: '/system',
     children: [
+      {
+        path: '/system/role',
+        name: 'SystemRole',
+        meta: {
+          icon: 'mdi:account-group',
+          title: $t('system.role.title'),
+        },
+        component: () => import('#/views/system/role/list.vue'),
+      },
+      {
+        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',

+ 19 - 3
playground/src/views/_core/authentication/login.vue

@@ -1,8 +1,8 @@
 <script lang="ts" setup>
 import type { VbenFormSchema } from '@vben/common-ui';
-import type { BasicOption } from '@vben/types';
+import type { BasicOption, Recordable } from '@vben/types';
 
-import { computed, markRaw } from 'vue';
+import { computed, markRaw, useTemplateRef } from 'vue';
 
 import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
 import { $t } from '@vben/locales';
@@ -104,12 +104,28 @@ const formSchema = computed((): VbenFormSchema[] => {
     },
   ];
 });
+
+const loginRef =
+  useTemplateRef<InstanceType<typeof AuthenticationLogin>>('loginRef');
+
+async function onSubmit(params: Recordable<any>) {
+  authStore.authLogin(params).catch(() => {
+    // 登陆失败,刷新验证码的演示
+
+    // 使用表单API获取验证码组件实例,并调用其resume方法来重置验证码
+    loginRef.value
+      ?.getFormApi()
+      ?.getFieldComponentRef<InstanceType<typeof SliderCaptcha>>('captcha')
+      ?.resume();
+  });
+}
 </script>
 
 <template>
   <AuthenticationLogin
+    ref="loginRef"
     :form-schema="formSchema"
     :loading="authStore.loginLoading"
-    @submit="authStore.authLogin"
+    @submit="onSubmit"
   />
 </template>

+ 21 - 1
playground/src/views/demos/features/hide-menu-children/children.vue

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

+ 8 - 2
playground/src/views/demos/features/hide-menu-children/parent.vue

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

+ 9 - 2
playground/src/views/examples/drawer/base-demo.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import { useVbenDrawer } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Button, message } from 'ant-design-vue';
 
 const [Drawer, drawerApi] = useVbenDrawer({
   onCancel() {
@@ -15,12 +15,19 @@ const [Drawer, drawerApi] = useVbenDrawer({
     // drawerApi.close();
   },
 });
+
+function lockDrawer() {
+  drawerApi.lock();
+  setTimeout(() => {
+    drawerApi.unlock();
+  }, 3000);
+}
 </script>
 <template>
   <Drawer title="基础抽屉示例" title-tooltip="标题提示内容">
     <template #extra> extra </template>
     base demo
-
+    <Button type="primary" @click="lockDrawer">锁定抽屉状态</Button>
     <!-- <template #prepend-footer> slot </template> -->
     <!-- <template #append-footer> prepend slot </template> -->
   </Drawer>

+ 6 - 1
playground/src/views/examples/drawer/in-content-demo.vue

@@ -1,7 +1,11 @@
 <script lang="ts" setup>
+import { ref } from 'vue';
+
 import { useVbenDrawer } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Input, message } from 'ant-design-vue';
+
+const value = ref('');
 
 const [Drawer, drawerApi] = useVbenDrawer({
   onCancel() {
@@ -17,5 +21,6 @@ const [Drawer, drawerApi] = useVbenDrawer({
   <Drawer append-to-main title="基础抽屉示例" title-tooltip="标题提示内容">
     <template #extra> extra </template>
     本抽屉指定在内容区域打开
+    <Input v-model="value" placeholder="KeepAlive测试" />
   </Drawer>
 </template>

+ 1 - 0
playground/src/views/examples/drawer/index.vue

@@ -13,6 +13,7 @@ import FormDrawerDemo from './form-drawer-demo.vue';
 import inContentDemo from './in-content-demo.vue';
 import SharedDataDemo from './shared-data-demo.vue';
 
+defineOptions({ name: 'DrawerExample' });
 const [BaseDrawer, baseDrawerApi] = useVbenDrawer({
   // 连接抽离的组件
   connectedComponent: BaseDemo,

+ 10 - 1
playground/src/views/examples/form/api.vue

@@ -1,4 +1,6 @@
 <script lang="ts" setup>
+import type { RefSelectProps } from 'ant-design-vue/es/select';
+
 import { ref } from 'vue';
 
 import { Page } from '@vben/common-ui';
@@ -82,6 +84,7 @@ function handleClick(
   action:
     | 'batchAddSchema'
     | 'batchDeleteSchema'
+    | 'componentRef'
     | 'disabled'
     | 'hiddenAction'
     | 'hiddenResetButton'
@@ -129,6 +132,11 @@ function handleClick(
       });
       break;
     }
+    case 'componentRef': {
+      // 获取下拉组件的实例,并调用它的focus方法
+      formApi.getFieldComponentRef<RefSelectProps>('fieldOptions')?.focus();
+      break;
+    }
     case 'disabled': {
       formApi.setState({ commonConfig: { disabled: true } });
       break;
@@ -182,6 +190,7 @@ function handleClick(
       formApi.setState({ submitButtonOptions: { show: true } });
       break;
     }
+
     case 'updateActionAlign': {
       formApi.setState({
         // 可以自行调整class
@@ -189,7 +198,6 @@ function handleClick(
       });
       break;
     }
-
     case 'updateResetButton': {
       formApi.setState({
         resetButtonOptions: { disabled: true },
@@ -257,6 +265,7 @@ function handleClick(
       <Button @click="handleClick('batchDeleteSchema')">
         批量删除表单项
       </Button>
+      <Button @click="handleClick('componentRef')">下拉组件获取焦点</Button>
     </Space>
     <Card title="操作示例">
       <BaseForm />

+ 9 - 1
playground/src/views/examples/modal/base-demo.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import { useVbenModal } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Button, message } from 'ant-design-vue';
 
 const [Modal, modalApi] = useVbenModal({
   onCancel() {
@@ -18,9 +18,17 @@ const [Modal, modalApi] = useVbenModal({
     message.info('onOpened:打开动画结束');
   },
 });
+
+function lockModal() {
+  modalApi.lock();
+  setTimeout(() => {
+    modalApi.unlock();
+  }, 3000);
+}
 </script>
 <template>
   <Modal class="w-[600px]" title="基础弹窗示例" title-tooltip="标题提示内容">
     base demo
+    <Button type="primary" @click="lockModal">锁定弹窗</Button>
   </Modal>
 </template>

+ 6 - 1
playground/src/views/examples/modal/in-content-demo.vue

@@ -1,9 +1,12 @@
 <script lang="ts" setup>
+import { ref } from 'vue';
+
 import { useVbenModal } from '@vben/common-ui';
 
-import { message } from 'ant-design-vue';
+import { Input, message } from 'ant-design-vue';
 
 const [Modal, modalApi] = useVbenModal({
+  destroyOnClose: false,
   onCancel() {
     modalApi.close();
   },
@@ -12,6 +15,7 @@ const [Modal, modalApi] = useVbenModal({
     // modalApi.close();
   },
 });
+const value = ref();
 </script>
 <template>
   <Modal
@@ -21,5 +25,6 @@ const [Modal, modalApi] = useVbenModal({
     title-tooltip="标题提示内容"
   >
     此弹窗指定在内容区域打开
+    <Input v-model="value" placeholder="KeepAlive测试" />
   </Modal>
 </template>

+ 2 - 0
playground/src/views/examples/modal/index.vue

@@ -14,6 +14,8 @@ import InContentModalDemo from './in-content-demo.vue';
 import NestedDemo from './nested-demo.vue';
 import SharedDataDemo from './shared-data-demo.vue';
 
+defineOptions({ name: 'ModalExample' });
+
 const [BaseModal, baseModalApi] = useVbenModal({
   // 连接抽离的组件
   connectedComponent: BaseDemo,

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

+ 127 - 0
playground/src/views/system/role/data.ts

@@ -0,0 +1,127 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { SystemRoleApi } from '#/api';
+
+import { $t } from '#/locales';
+
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.role.roleName'),
+      rules: 'required',
+    },
+    {
+      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.role.status'),
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'remark',
+      label: $t('system.role.remark'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'permissions',
+      formItemClass: 'items-start',
+      label: $t('system.role.setPermissions'),
+      modelPropName: 'modelValue',
+    },
+  ];
+}
+
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.role.roleName'),
+    },
+    { component: 'Input', fieldName: 'id', label: $t('system.role.id') },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: [
+          { label: $t('common.enabled'), value: 1 },
+          { label: $t('common.disabled'), value: 0 },
+        ],
+      },
+      fieldName: 'status',
+      label: $t('system.role.status'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'remark',
+      label: $t('system.role.remark'),
+    },
+    {
+      component: 'RangePicker',
+      fieldName: 'createTime',
+      label: $t('system.role.createTime'),
+    },
+  ];
+}
+
+export function useColumns<T = SystemRoleApi.SystemRole>(
+  onActionClick: OnActionClickFn<T>,
+  onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
+): VxeTableGridOptions['columns'] {
+  return [
+    {
+      field: 'name',
+      title: $t('system.role.roleName'),
+      width: 200,
+    },
+    {
+      field: 'id',
+      title: $t('system.role.id'),
+      width: 200,
+    },
+    {
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      title: $t('system.role.status'),
+      width: 100,
+    },
+    {
+      field: 'remark',
+      minWidth: 100,
+      title: $t('system.role.remark'),
+    },
+    {
+      field: 'createTime',
+      title: $t('system.role.createTime'),
+      width: 200,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.role.name'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('system.role.operation'),
+      width: 130,
+    },
+  ];
+}

+ 164 - 0
playground/src/views/system/role/list.vue

@@ -0,0 +1,164 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemRoleApi } from '#/api';
+
+import { Page, useVbenDrawer } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteRole, getRoleList, updateRole } from '#/api';
+import { $t } from '#/locales';
+
+import { useColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const [FormDrawer, formDrawerApi] = useVbenDrawer({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
+    schema: useGridFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useColumns(onActionClick, onStatusChange),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getRoleList({
+            page: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: { code: 'query' },
+      search: true,
+      zoom: true,
+    },
+  } as VxeTableGridOptions<SystemRoleApi.SystemRole>,
+});
+
+function onActionClick(e: OnActionClickParams<SystemRoleApi.SystemRole>) {
+  switch (e.code) {
+    case 'delete': {
+      onDelete(e.row);
+      break;
+    }
+    case 'edit': {
+      onEdit(e.row);
+      break;
+    }
+  }
+}
+
+/**
+ * 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
+ * @param content 提示内容
+ * @param title 提示标题
+ */
+function confirm(content: string, title: string) {
+  return new Promise((reslove, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        reslove(true);
+      },
+      title,
+    });
+  });
+}
+
+/**
+ * 状态开关即将改变
+ * @param newStatus 期望改变的状态值
+ * @param row 行数据
+ * @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
+ */
+async function onStatusChange(
+  newStatus: number,
+  row: SystemRoleApi.SystemRole,
+) {
+  const status: Recordable<string> = {
+    0: '禁用',
+    1: '启用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      `切换状态`,
+    );
+    await updateRole(row.id, { status: newStatus });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function onEdit(row: SystemRoleApi.SystemRole) {
+  formDrawerApi.setData(row).open();
+}
+
+function onDelete(row: SystemRoleApi.SystemRole) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  deleteRole(row.id)
+    .then(() => {
+      message.success({
+        content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+        key: 'action_process_msg',
+      });
+      onRefresh();
+    })
+    .catch(() => {
+      hideLoading();
+    });
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onCreate() {
+  formDrawerApi.setData({}).open();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormDrawer />
+    <Grid :table-title="$t('system.role.list')">
+      <template #toolbar-tools>
+        <Button type="primary" @click="onCreate">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 139 - 0
playground/src/views/system/role/modules/form.vue

@@ -0,0 +1,139 @@
+<script lang="ts" setup>
+import type { DataNode } from 'ant-design-vue/es/tree';
+
+import type { Recordable } from '@vben/types';
+
+import type { SystemRoleApi } from '#/api/system/role';
+
+import { computed, ref } from 'vue';
+
+import { useVbenDrawer, VbenTree } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { Spin } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { getMenuList } from '#/api/system/menu';
+import { createRole, updateRole } from '#/api/system/role';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emits = defineEmits(['success']);
+
+const formData = ref<SystemRoleApi.SystemRole>();
+
+const [Form, formApi] = useVbenForm({
+  schema: useFormSchema(),
+  showDefaultActions: false,
+});
+
+const permissions = ref<DataNode[]>([]);
+const loadingPermissions = ref(false);
+
+const id = ref();
+const [Drawer, drawerApi] = useVbenDrawer({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    const values = await formApi.getValues();
+    drawerApi.lock();
+    (id.value ? updateRole(id.value, values) : createRole(values))
+      .then(() => {
+        emits('success');
+        drawerApi.close();
+      })
+      .catch(() => {
+        drawerApi.unlock();
+      });
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = drawerApi.getData<SystemRoleApi.SystemRole>();
+      formApi.resetForm();
+      if (data) {
+        formData.value = data;
+        id.value = data.id;
+        formApi.setValues(data);
+      } else {
+        id.value = undefined;
+      }
+
+      if (permissions.value.length === 0) {
+        loadPermissions();
+      }
+    }
+  },
+});
+
+async function loadPermissions() {
+  loadingPermissions.value = true;
+  try {
+    const res = await getMenuList();
+    permissions.value = res as unknown as DataNode[];
+  } finally {
+    loadingPermissions.value = false;
+  }
+}
+
+const getDrawerTitle = computed(() => {
+  return formData.value?.id
+    ? $t('common.edit', $t('system.role.name'))
+    : $t('common.create', $t('system.role.name'));
+});
+
+function getNodeClass(node: Recordable<any>) {
+  const classes: string[] = [];
+  if (node.value?.type === 'button') {
+    classes.push('inline-flex');
+    if (node.index % 3 >= 1) {
+      classes.push('!pl-0');
+    }
+  }
+
+  return classes.join(' ');
+}
+</script>
+<template>
+  <Drawer :title="getDrawerTitle">
+    <Form>
+      <template #permissions="slotProps">
+        <Spin :spinning="loadingPermissions">
+          <VbenTree
+            :tree-data="permissions"
+            multiple
+            bordered
+            :default-expanded-level="2"
+            :get-node-class="getNodeClass"
+            v-bind="slotProps"
+            value-field="id"
+            label-field="meta.title"
+            icon-field="meta.icon"
+          >
+            <template #node="{ value }">
+              <IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
+              {{ $t(value.meta.title) }}
+            </template>
+          </VbenTree>
+        </Spin>
+      </template>
+    </Form>
+  </Drawer>
+</template>
+<style lang="css" scoped>
+:deep(.ant-tree-title) {
+  .tree-actions {
+    display: none;
+    margin-left: 20px;
+  }
+}
+
+:deep(.ant-tree-title:hover) {
+  .tree-actions {
+    display: flex;
+    flex: auto;
+    justify-content: flex-end;
+    margin-left: 20px;
+  }
+}
+</style>

Fichier diff supprimé car celui-ci est trop grand
+ 195 - 273
pnpm-lock.yaml


+ 48 - 48
pnpm-workspace.yaml

@@ -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
+  '@commitlint/cli': ^19.8.0
+  '@commitlint/config-conventional': ^19.8.0
   '@ctrl/tinycolor': ^4.1.0
-  '@eslint/js': ^9.20.0
-  '@faker-js/faker': ^9.5.0
-  '@iconify/json': ^2.2.307
+  '@eslint/js': ^9.22.0
+  '@faker-js/faker': ^9.6.0
+  '@iconify/json': ^2.2.314
   '@iconify/tailwind': ^1.2.0
   '@iconify/vue': ^4.3.0
-  '@intlify/core-base': ^11.1.1
+  '@intlify/core-base': ^11.1.2
   '@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
-  '@playwright/test': ^1.50.1
-  '@pnpm/workspace.read-manifest': ^1000.0.2
-  '@stylistic/stylelint-plugin': ^3.1.1
+  '@nolebase/vitepress-plugin-git-changelog': ^2.15.0
+  '@playwright/test': ^1.51.0
+  '@pnpm/workspace.read-manifest': ^1000.1.1
+  '@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.67.2
   '@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.10
   '@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.26.0
+  '@typescript-eslint/parser': ^8.26.0
   '@vee-validate/zod': ^4.15.0
   '@vite-pwa/vitepress': ^0.5.3
   '@vitejs/plugin-vue': ^5.2.1
@@ -59,13 +59,13 @@ catalog:
   '@vue/reactivity': ^3.5.13
   '@vue/shared': ^3.5.13
   '@vue/test-utils': ^2.4.6
-  '@vueuse/core': ^12.7.0
+  '@vueuse/core': ^12.8.2
   '@vueuse/motion': ^2.2.6
-  '@vueuse/integrations': ^12.7.0
+  '@vueuse/integrations': ^12.8.2
   ant-design-vue: ^4.2.6
   archiver: ^7.0.1
   autoprefixer: ^10.4.20
-  axios: ^1.7.9
+  axios: ^1.8.2
   axios-mock-adapter: ^2.1.0
   cac: ^6.7.14
   chalk: ^5.4.1
@@ -76,37 +76,37 @@ catalog:
   commitlint-plugin-function-rules: ^4.0.1
   consola: ^3.4.0
   cross-env: ^7.0.3
-  cspell: ^8.17.3
+  cspell: ^8.17.5
   cssnano: ^7.0.6
-  cz-git: ^1.11.0
-  czg: ^1.11.0
+  cz-git: ^1.11.1
+  czg: ^1.11.1
   dayjs: ^1.11.13
   defu: ^6.1.4
   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.6
+  eslint: ^9.22.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
   eslint-plugin-jsdoc: ^50.6.3
   eslint-plugin-jsonc: ^2.19.1
-  eslint-plugin-n: ^17.15.1
+  eslint-plugin-n: ^17.16.2
   eslint-plugin-no-only-tests: ^3.3.0
-  eslint-plugin-perfectionist: ^4.9.0
+  eslint-plugin-perfectionist: ^4.10.0
   eslint-plugin-prettier: ^5.2.3
   eslint-plugin-regexp: ^2.7.0
   eslint-plugin-unicorn: ^56.0.1
   eslint-plugin-unused-imports: ^4.1.4
   eslint-plugin-vitest: ^0.5.4
-  eslint-plugin-vue: ^9.32.0
+  eslint-plugin-vue: ^9.33.0
   execa: ^9.5.2
   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
@@ -120,31 +120,31 @@ catalog:
   lucide-vue-next: ^0.469.0
   medium-zoom: ^1.1.0
   naive-ui: ^2.41.0
-  nitropack: ^2.10.4
+  nitropack: ^2.11.6
   nprogress: ^0.2.0
   ora: ^8.2.0
   pinia: ^2.3.1
   pinia-plugin-persistedstate: ^4.2.0
   pkg-types: ^1.3.1
-  playwright: ^1.50.1
-  postcss: ^8.5.2
+  playwright: ^1.51.0
+  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.3
   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.35.0
   rollup-plugin-visualizer: ^5.14.0
-  sass: ^1.85.0
+  sass: ^1.85.1
   sortablejs: ^1.15.6
-  stylelint: ^16.14.1
+  stylelint: ^16.15.0
   stylelint-config-recess-order: ^5.1.1
   stylelint-config-recommended: ^14.0.1
   stylelint-config-recommended-scss: ^14.1.0
@@ -152,35 +152,35 @@ 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.1
   vite-plugin-compression: ^0.5.1
-  vite-plugin-dts: ^4.5.0
+  vite-plugin-dts: ^4.5.3
   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
-  vue-i18n: ^11.1.1
+  vue-i18n: ^11.1.2
   vue-json-viewer: ^3.0.4
   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.4.8
   vxe-table: 4.10.0
   watermark-js-plus: ^1.5.8
   zod: ^3.24.2

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff