Explorar el Código

Merge tag 'v5.5.4' into admin-dz

laiqi hace 1 año
padre
commit
b4cab812bc
Se han modificado 100 ficheros con 2532 adiciones y 157 borrados
  1. 15 0
      apps/backend-mock/api/system/dept/.post.ts
  2. 15 0
      apps/backend-mock/api/system/dept/[id].delete.ts
  3. 15 0
      apps/backend-mock/api/system/dept/[id].put.ts
  4. 61 0
      apps/backend-mock/api/system/dept/list.ts
  5. 12 0
      apps/backend-mock/api/system/menu/list.ts
  6. 28 0
      apps/backend-mock/api/system/menu/name-exists.ts
  7. 28 0
      apps/backend-mock/api/system/menu/path-exists.ts
  8. 83 0
      apps/backend-mock/api/system/role/list.ts
  9. 1 1
      apps/web-ele/package.json
  10. 26 6
      apps/web-ele/src/adapter/component/index.ts
  11. 7 1
      apps/web-ele/src/bootstrap.ts
  12. 1 1
      internal/lint-configs/commitlint-config/package.json
  13. 1 1
      internal/lint-configs/eslint-config/src/configs/javascript.ts
  14. 1 1
      internal/lint-configs/stylelint-config/package.json
  15. 1 1
      internal/node-utils/package.json
  16. 1 1
      internal/tailwind-config/package.json
  17. 1 1
      internal/tsconfig/package.json
  18. 1 1
      internal/vite-config/package.json
  19. 2 2
      package.json
  20. 1 1
      packages/@core/base/design/package.json
  21. 1 1
      packages/@core/base/icons/package.json
  22. 6 0
      packages/@core/base/icons/src/lucide.ts
  23. 1 1
      packages/@core/base/shared/package.json
  24. 1 1
      packages/@core/base/typings/package.json
  25. 1 1
      packages/@core/composables/package.json
  26. 1 1
      packages/@core/preferences/package.json
  27. 1 1
      packages/@core/ui-kit/form-ui/package.json
  28. 51 3
      packages/@core/ui-kit/form-ui/src/form-api.ts
  29. 56 44
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  30. 12 3
      packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
  31. 3 3
      packages/@core/ui-kit/form-ui/src/types.ts
  32. 3 0
      packages/@core/ui-kit/form-ui/src/use-form-context.ts
  33. 9 2
      packages/@core/ui-kit/form-ui/src/vben-use-form.vue
  34. 1 1
      packages/@core/ui-kit/layout-ui/package.json
  35. 9 3
      packages/@core/ui-kit/layout-ui/src/vben-layout.vue
  36. 1 1
      packages/@core/ui-kit/menu-ui/package.json
  37. 24 1
      packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
  38. 6 2
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
  39. 22 6
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
  40. 8 0
      packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
  41. 5 2
      packages/@core/ui-kit/popup-ui/src/modal/modal.vue
  42. 1 1
      packages/@core/ui-kit/shadcn-ui/package.json
  43. 15 1
      packages/@core/ui-kit/shadcn-ui/src/components/avatar/avatar.vue
  44. 19 2
      packages/@core/ui-kit/shadcn-ui/src/components/breadcrumb/breadcrumb-view.vue
  45. 98 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/button-group.vue
  46. 18 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/button.ts
  47. 163 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/check-button-group.vue
  48. 2 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/index.ts
  49. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/checkbox/checkbox.vue
  50. 20 3
      packages/@core/ui-kit/shadcn-ui/src/components/count-to-animator/count-to-animator.vue
  51. 2 1
      packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
  52. 14 11
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/loading.vue
  53. 2 1
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue
  54. 6 4
      packages/@core/ui-kit/shadcn-ui/src/ui/checkbox/Checkbox.vue
  55. 3 1
      packages/@core/ui-kit/shadcn-ui/src/ui/dialog/DialogContent.vue
  56. 1 0
      packages/@core/ui-kit/shadcn-ui/src/ui/index.ts
  57. 2 0
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts
  58. 301 0
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue
  59. 1 1
      packages/@core/ui-kit/tabs-ui/package.json
  60. 1 1
      packages/constants/package.json
  61. 1 1
      packages/effects/access/package.json
  62. 29 0
      packages/effects/access/src/accessible.ts
  63. 1 1
      packages/effects/common-ui/package.json
  64. 1 1
      packages/effects/common-ui/src/components/count-to/count-to.vue
  65. 5 0
      packages/effects/common-ui/src/components/index.ts
  66. 132 0
      packages/effects/common-ui/src/components/loading/directive.ts
  67. 3 0
      packages/effects/common-ui/src/components/loading/index.ts
  68. 39 0
      packages/effects/common-ui/src/components/loading/loading.vue
  69. 28 0
      packages/effects/common-ui/src/components/loading/spinner.vue
  70. 1 4
      packages/effects/common-ui/src/ui/authentication/code-login.vue
  71. 1 1
      packages/effects/hooks/package.json
  72. 1 1
      packages/effects/layouts/package.json
  73. 1 1
      packages/effects/plugins/package.json
  74. 25 4
      packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
  75. 1 1
      packages/effects/request/package.json
  76. 1 1
      packages/icons/package.json
  77. 1 1
      packages/locales/package.json
  78. 2 0
      packages/locales/src/index.ts
  79. 9 1
      packages/locales/src/langs/en-US/common.json
  80. 20 1
      packages/locales/src/langs/en-US/ui.json
  81. 9 1
      packages/locales/src/langs/zh-CN/common.json
  82. 20 1
      packages/locales/src/langs/zh-CN/ui.json
  83. 1 1
      packages/preferences/package.json
  84. 1 1
      packages/stores/package.json
  85. 1 1
      packages/styles/package.json
  86. 1 1
      packages/types/package.json
  87. 1 1
      packages/utils/package.json
  88. 8 6
      packages/utils/src/helpers/generate-routes-backend.ts
  89. 6 2
      packages/utils/src/helpers/get-popup-container.ts
  90. 54 0
      playground/src/api/system/dept.ts
  91. 3 0
      playground/src/api/system/index.ts
  92. 158 0
      playground/src/api/system/menu.ts
  93. 55 0
      playground/src/api/system/role.ts
  94. 65 0
      playground/src/locales/langs/en-US/system.json
  95. 67 0
      playground/src/locales/langs/zh-CN/system.json
  96. 46 0
      playground/src/router/routes/modules/system.ts
  97. 194 0
      playground/src/views/examples/button-group/index.vue
  98. 111 0
      playground/src/views/examples/form/custom-layout.vue
  99. 101 0
      playground/src/views/examples/loading/index.vue
  100. 135 0
      playground/src/views/system/dept/data.ts

+ 15 - 0
apps/backend-mock/api/system/dept/.post.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(600);
+  return useResponseSuccess(null);
+});

+ 15 - 0
apps/backend-mock/api/system/dept/[id].delete.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(1000);
+  return useResponseSuccess(null);
+});

+ 15 - 0
apps/backend-mock/api/system/dept/[id].put.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(2000);
+  return useResponseSuccess(null);
+});

+ 61 - 0
apps/backend-mock/api/system/dept/list.ts

@@ -0,0 +1,61 @@
+import { faker } from '@faker-js/faker';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse, useResponseSuccess } 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',
+});
+
+function generateMockDataList(count: number) {
+  const dataList = [];
+
+  for (let i = 0; i < count; i++) {
+    const dataItem: Record<string, any> = {
+      id: faker.string.uuid(),
+      pid: 0,
+      name: faker.commerce.department(),
+      status: faker.helpers.arrayElement([0, 1]),
+      createTime: formatterCN.format(
+        faker.date.between({ from: '2021-01-01', to: '2022-12-31' }),
+      ),
+      remark: faker.lorem.sentence(),
+    };
+    if (faker.datatype.boolean()) {
+      dataItem.children = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => ({
+          id: faker.string.uuid(),
+          pid: dataItem.id,
+          name: faker.commerce.department(),
+          status: faker.helpers.arrayElement([0, 1]),
+          createTime: formatterCN.format(
+            faker.date.between({ from: '2023-01-01', to: '2023-12-31' }),
+          ),
+          remark: faker.lorem.sentence(),
+        }),
+      );
+    }
+    dataList.push(dataItem);
+  }
+
+  return dataList;
+}
+
+const mockData = generateMockDataList(10);
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+
+  const listData = structuredClone(mockData);
+
+  return useResponseSuccess(listData);
+});

+ 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/web-ele/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/web-ele",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://vben.pro",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 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);
+    },
+  });
 };
 
 // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明

+ 7 - 1
apps/web-ele/src/bootstrap.ts

@@ -1,7 +1,7 @@
 import { createApp, watchEffect } from 'vue';
 
 import { registerAccessDirective } from '@vben/access';
-import { initTippy } from '@vben/common-ui';
+import { initTippy, registerLoadingDirective } from '@vben/common-ui';
 import { MotionPlugin } from '@vben/plugins/motion';
 import { preferences } from '@vben/preferences';
 import { initStores } from '@vben/stores';
@@ -33,6 +33,12 @@ async function bootstrap(namespace: string) {
   // 注册Element Plus提供的v-loading指令
   app.directive('loading', ElLoading.directive);
 
+  // 注册Vben提供的v-loading和v-spinning指令
+  registerLoadingDirective(app, {
+    loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册Vben提供的v-loading指令
+    spinning: 'spinning',
+  });
+
   // 国际化 i18n 配置
   await setupI18n(app);
 

+ 1 - 1
internal/lint-configs/commitlint-config/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/commitlint-config",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 1 - 1
internal/lint-configs/eslint-config/src/configs/javascript.ts

@@ -174,7 +174,7 @@ export async function javascript(): Promise<Linter.Config[]> {
         ],
         'no-use-before-define': [
           'error',
-          { classes: false, functions: false, variables: true },
+          { classes: false, functions: false, variables: false },
         ],
         'no-useless-backreference': 'error',
         'no-useless-call': 'error',

+ 1 - 1
internal/lint-configs/stylelint-config/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/stylelint-config",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 1 - 1
internal/node-utils/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/node-utils",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 1 - 1
internal/tailwind-config/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/tailwind-config",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 1 - 1
internal/tsconfig/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/tsconfig",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 1 - 1
internal/vite-config/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/vite-config",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "vben-admin-monorepo",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "private": true,
   "keywords": [
     "monorepo",
@@ -96,7 +96,7 @@
     "node": ">=20.10.0",
     "pnpm": ">=9.12.0"
   },
-  "packageManager": "pnpm@9.15.5",
+  "packageManager": "pnpm@9.15.7",
   "pnpm": {
     "peerDependencyRules": {
       "allowedVersions": {

+ 1 - 1
packages/@core/base/design/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/design",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/@core/base/icons/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/icons",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

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

@@ -14,6 +14,8 @@ export {
   ChevronRight,
   ChevronsLeft,
   ChevronsRight,
+  Circle,
+  CircleCheckBig,
   CircleHelp,
   Copy,
   CornerDownLeft,
@@ -47,11 +49,15 @@ export {
   PanelRight,
   Pin,
   PinOff,
+  Plus,
   RotateCw,
   Search,
   SearchX,
   Settings,
   Shrink,
+  Square,
+  SquareCheckBig,
+  SquareMinus,
   Sun,
   SunMoon,
   SwatchBook,

+ 1 - 1
packages/@core/base/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/shared",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/@core/base/typings/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/typings",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/@core/composables/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/composables",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/@core/preferences/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/preferences",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/@core/ui-kit/form-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/form-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

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

+ 56 - 44
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';
@@ -193,7 +194,7 @@ const fieldProps = computed(() => {
   const rules = fieldRules.value;
   return {
     keepValue: true,
-    label,
+    label: isString(label) ? label : '',
     ...(rules ? { rules } : {}),
     ...(formFieldProps as Record<string, any>),
   };
@@ -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>
@@ -285,7 +295,7 @@ function autofocus() {
         'pb-6': !compact,
         'pb-2': compact,
       }"
-      class="flex"
+      class="relative flex"
       v-bind="$attrs"
     >
       <FormLabel
@@ -301,59 +311,61 @@ function autofocus() {
           )
         "
         :help="help"
+        :colon="colon"
+        :label="label"
         :required="shouldRequired && !hideRequiredMark"
         :style="labelStyle"
       >
         <template v-if="label">
-          <span>{{ label }}</span>
-          <span v-if="colon" class="ml-[2px]">:</span>
+          <VbenRenderContent :content="label" />
         </template>
       </FormLabel>
-      <div :class="cn('relative flex w-full items-center', wrapperClass)">
-        <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,
+      <div class="flex-auto overflow-hidden">
+        <div :class="cn('relative flex w-full items-center', wrapperClass)">
+          <FormControl :class="cn(controlClass)">
+            <slot
+              v-bind="{
+                ...slotProps,
+                ...createComponentProps(slotProps),
+                disabled: shouldDisabled,
+                isInValid,
               }"
-              v-bind="createComponentProps(slotProps)"
-              :disabled="shouldDisabled"
             >
-              <template
-                v-for="name in renderContentKey"
-                :key="name"
-                #[name]="renderSlotProps"
+              <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"
               >
-                <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" />
+                <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 v-if="suffix" class="ml-1">
+            <VbenRenderContent :content="suffix" />
+          </div>
+          <FormDescription v-if="description" class="ml-1">
+            <VbenRenderContent :content="description" />
+          </FormDescription>
         </div>
 
-        <FormDescription v-if="description">
-          <VbenRenderContent :content="description" />
-        </FormDescription>
-
         <Transition name="slide-up">
-          <FormMessage class="absolute -bottom-[22px]" />
+          <FormMessage class="absolute bottom-1" />
         </Transition>
       </div>
     </FormItem>

+ 12 - 3
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue

@@ -1,10 +1,18 @@
 <script setup lang="ts">
-import { FormLabel, VbenHelpTooltip } from '@vben-core/shadcn-ui';
+import type { CustomRenderType } from '../types';
+
+import {
+  FormLabel,
+  VbenHelpTooltip,
+  VbenRenderContent,
+} from '@vben-core/shadcn-ui';
 import { cn } from '@vben-core/shared/utils';
 
 interface Props {
   class?: string;
-  help?: string;
+  colon?: boolean;
+  help?: CustomRenderType;
+  label?: CustomRenderType;
   required?: boolean;
 }
 
@@ -16,7 +24,8 @@ const props = defineProps<Props>();
     <span v-if="required" class="text-destructive mr-[2px]">*</span>
     <slot></slot>
     <VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
-      {{ help }}
+      <VbenRenderContent :content="help" />
     </VbenHelpTooltip>
+    <span v-if="colon && label" class="ml-[2px]">:</span>
   </FormLabel>
 </template>

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

@@ -244,13 +244,13 @@ export interface FormSchema<
   /** 依赖 */
   dependencies?: FormItemDependencies;
   /** 描述 */
-  description?: string;
+  description?: CustomRenderType;
   /** 字段名 */
   fieldName: string;
   /** 帮助信息 */
-  help?: string;
+  help?: CustomRenderType;
   /** 表单项 */
-  label?: string;
+  label?: CustomRenderType;
   // 自定义组件内部渲染
   renderComponentContent?: RenderComponentContentType;
   /** 字段规则 */

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

+ 1 - 1
packages/@core/ui-kit/layout-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/layout-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 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状态展开菜单中

+ 1 - 1
packages/@core/ui-kit/menu-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/menu-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 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,
+      }));
     }
   }
 
@@ -100,6 +106,15 @@ export class DrawerApi {
   }
 
   /**
+   * 锁定抽屉状态(用于提交过程中的等待状态)
+   * @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖抽屉内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
+   * @param isLocked 是否锁定
+   */
+  lock(isLocked: boolean = true) {
+    return this.setState({ submitting: isLocked });
+  }
+
+  /**
    * 取消操作
    */
   onCancel() {
@@ -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
@@ -106,6 +106,10 @@ export interface DrawerProps {
    */
   showConfirmButton?: boolean;
   /**
+   * 提交中(锁定抽屉状态)
+   */
+  submitting?: boolean;
+  /**
    * 弹窗标题
    */
   title?: string;

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

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/shadcn-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "#main": "./dist/index.mjs",
   "#module": "./dist/index.mjs",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",

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

+ 98 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/button-group.vue

@@ -0,0 +1,98 @@
+<script lang="ts" setup>
+import { cn } from '@vben-core/shared/utils';
+
+defineOptions({ name: 'VbenButtonGroup' });
+
+withDefaults(
+  defineProps<{
+    border?: boolean;
+    gap?: number;
+    size?: 'large' | 'middle' | 'small';
+  }>(),
+  { border: false, gap: 0, size: 'middle' },
+);
+</script>
+<template>
+  <div
+    :class="
+      cn(
+        'vben-button-group rounded-md',
+        `size-${size}`,
+        gap ? 'with-gap' : 'no-gap',
+        $attrs.class as string,
+      )
+    "
+    :style="{ gap: gap ? `${gap}px` : '0px' }"
+  >
+    <slot></slot>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.vben-button-group {
+  display: inline-flex;
+
+  &.size-large :deep(button) {
+    height: 2.25rem;
+    padding: 0.5rem 0.75rem;
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+
+    .icon-wrapper {
+      margin-right: 0.4rem;
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+
+  &.size-middle :deep(button) {
+    height: 2rem;
+    padding: 0.25rem 0.5rem;
+    font-size: 0.75rem;
+    line-height: 1rem;
+
+    .icon-wrapper {
+      margin-right: 0.2rem;
+
+      svg {
+        width: 0.75rem;
+        height: 0.75rem;
+      }
+    }
+  }
+
+  &.size-small :deep(button) {
+    height: 1.75rem;
+    padding: 0.2rem 0.4rem;
+    font-size: 0.65rem;
+    line-height: 0.75rem;
+
+    .icon-wrapper {
+      margin-right: 0.1rem;
+
+      svg {
+        width: 0.65rem;
+        height: 0.65rem;
+      }
+    }
+  }
+
+  &.no-gap > :deep(button):nth-of-type(1) {
+    border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
+  }
+
+  &.no-gap > :deep(button):last-of-type {
+    border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
+  }
+
+  &.no-gap {
+    :deep(button + button) {
+      border-left-width: 0;
+      border-radius: 0;
+    }
+  }
+}
+</style>

+ 18 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/button.ts

@@ -22,3 +22,21 @@ export interface VbenButtonProps {
   size?: ButtonVariantSize;
   variant?: ButtonVariants;
 }
+
+export type CustomRenderType = (() => Component | string) | string;
+
+export type ValueType = boolean | number | string;
+
+export interface VbenButtonGroupProps
+  extends Pick<VbenButtonProps, 'disabled'> {
+  beforeChange?: (
+    value: ValueType,
+    isChecked: boolean,
+  ) => boolean | PromiseLike<boolean | undefined> | undefined;
+  btnClass?: any;
+  gap?: number;
+  multiple?: boolean;
+  options?: { label: CustomRenderType; value: ValueType }[];
+  showIcon?: boolean;
+  size?: 'large' | 'middle' | 'small';
+}

+ 163 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/check-button-group.vue

@@ -0,0 +1,163 @@
+<script lang="ts" setup>
+import type { Arrayable } from '@vueuse/core';
+
+import type { ValueType, VbenButtonGroupProps } from './button';
+
+import { computed, ref, watch } from 'vue';
+
+import { Circle, CircleCheckBig, LoaderCircle } from '@vben-core/icons';
+import { VbenRenderContent } from '@vben-core/shadcn-ui';
+import { cn, isFunction } from '@vben-core/shared/utils';
+
+import { objectOmit } from '@vueuse/core';
+
+import VbenButtonGroup from './button-group.vue';
+import Button from './button.vue';
+
+const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
+  gap: 0,
+  multiple: false,
+  showIcon: true,
+  size: 'middle',
+});
+
+const btnDefaultProps = computed(() => {
+  return {
+    ...objectOmit(props, ['options', 'btnClass', 'size', 'disabled']),
+    class: cn(props.btnClass),
+  };
+});
+const modelValue = defineModel<Arrayable<ValueType> | undefined>();
+
+const innerValue = ref<Array<ValueType>>([]);
+const loadingValues = ref<Array<ValueType>>([]);
+watch(
+  () => props.multiple,
+  (val) => {
+    if (val) {
+      modelValue.value = innerValue.value;
+    } else {
+      modelValue.value =
+        innerValue.value.length > 0 ? innerValue.value[0] : undefined;
+    }
+  },
+  { immediate: true },
+);
+
+watch(
+  () => modelValue.value,
+  (val) => {
+    if (Array.isArray(val)) {
+      const arrVal = val.filter((v) => v !== undefined);
+      if (arrVal.length > 0) {
+        innerValue.value = props.multiple
+          ? [...arrVal]
+          : [arrVal[0] as ValueType];
+      } else {
+        innerValue.value = [];
+      }
+    } else {
+      innerValue.value = val === undefined ? [] : [val as ValueType];
+    }
+  },
+  { deep: true },
+);
+
+async function onBtnClick(value: ValueType) {
+  if (props.beforeChange && isFunction(props.beforeChange)) {
+    try {
+      loadingValues.value.push(value);
+      const canChange = await props.beforeChange(
+        value,
+        !innerValue.value.includes(value),
+      );
+      if (canChange === false) {
+        return;
+      }
+    } finally {
+      loadingValues.value.splice(loadingValues.value.indexOf(value), 1);
+    }
+  }
+
+  if (props.multiple) {
+    if (innerValue.value.includes(value)) {
+      innerValue.value = innerValue.value.filter((item) => item !== value);
+    } else {
+      innerValue.value.push(value);
+    }
+    modelValue.value = innerValue.value;
+  } else {
+    innerValue.value = [value];
+    modelValue.value = value;
+  }
+}
+</script>
+<template>
+  <VbenButtonGroup
+    :size="props.size"
+    :gap="props.gap"
+    class="vben-check-button-group"
+  >
+    <Button
+      v-for="(btn, index) in props.options"
+      :key="index"
+      :class="cn('border', props.btnClass)"
+      :disabled="
+        props.disabled ||
+        loadingValues.includes(btn.value) ||
+        (!props.multiple && loadingValues.length > 0)
+      "
+      v-bind="btnDefaultProps"
+      :variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
+      @click="onBtnClick(btn.value)"
+    >
+      <div class="icon-wrapper" v-if="props.showIcon">
+        <LoaderCircle
+          class="animate-spin"
+          v-if="loadingValues.includes(btn.value)"
+        />
+        <CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
+        <Circle v-else />
+      </div>
+      <slot name="option" :label="btn.label" :value="btn.value">
+        <VbenRenderContent :content="btn.label" />
+      </slot>
+    </Button>
+  </VbenButtonGroup>
+</template>
+<style lang="scss" scoped>
+.vben-check-button-group {
+  &:deep(.size-large) button {
+    .icon-wrapper {
+      margin-right: 0.3rem;
+
+      svg {
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+
+  &:deep(.size-middle) button {
+    .icon-wrapper {
+      margin-right: 0.2rem;
+
+      svg {
+        width: 0.75rem;
+        height: 0.75rem;
+      }
+    }
+  }
+
+  &:deep(.size-small) button {
+    .icon-wrapper {
+      margin-right: 0.1rem;
+
+      svg {
+        width: 0.65rem;
+        height: 0.65rem;
+      }
+    }
+  }
+}
+</style>

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

@@ -1,3 +1,5 @@
 export type * from './button';
+export { default as VbenButtonGroup } from './button-group.vue';
 export { default as VbenButton } from './button.vue';
+export { default as VbenCheckButtonGroup } from './check-button-group.vue';
 export { default as VbenIconButton } from './icon-button.vue';

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

+ 20 - 3
packages/@core/ui-kit/shadcn-ui/src/components/count-to-animator/count-to-animator.vue

@@ -37,7 +37,18 @@ const props = withDefaults(defineProps<Props>(), {
   useEasing: true,
 });
 
-const emit = defineEmits(['onStarted', 'onFinished']);
+const emit = defineEmits<{
+  finished: [];
+  /**
+   * @deprecated 请使用{@link finished}事件
+   */
+  onFinished: [];
+  /**
+   * @deprecated 请使用{@link started}事件
+   */
+  onStarted: [];
+  started: [];
+}>();
 
 const source = ref(props.startVal);
 const disabled = ref(false);
@@ -73,8 +84,14 @@ function run() {
   outputValue = useTransition(source, {
     disabled,
     duration: props.duration,
-    onFinished: () => emit('onFinished'),
-    onStarted: () => emit('onStarted'),
+    onFinished: () => {
+      emit('finished');
+      emit('onFinished');
+    },
+    onStarted: () => {
+      emit('started');
+      emit('onStarted');
+    },
     ...(props.useEasing
       ? { transition: TransitionPresets[props.transition] }
       : {}),

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

+ 14 - 11
packages/@core/ui-kit/shadcn-ui/src/components/spinner/loading.vue

@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
 });
 // const startTime = ref(0);
 const showSpinner = ref(false);
-const renderSpinner = ref(true);
+const renderSpinner = ref(false);
 const timer = ref<ReturnType<typeof setTimeout>>();
 
 watch(
@@ -69,7 +69,7 @@ function onTransitionEnd() {
   <div
     :class="
       cn(
-        'z-100 dark:bg-overlay bg-overlay-content pointer-events-none absolute left-0 top-0 flex size-full flex-col items-center justify-center transition-all duration-500',
+        'z-100 dark:bg-overlay bg-overlay-content absolute left-0 top-0 flex size-full flex-col items-center justify-center transition-all duration-500',
         {
           'invisible opacity-0': !showSpinner,
         },
@@ -78,15 +78,18 @@ function onTransitionEnd() {
     "
     @transitionend="onTransitionEnd"
   >
-    <span class="dot relative inline-block size-9 text-3xl">
-      <i
-        v-for="index in 4"
-        :key="index"
-        class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
-      ></i>
-    </span>
-
-    <div v-if="text" class="mt-4 text-xs">{{ text }}</div>
+    <slot name="icon" v-if="renderSpinner">
+      <span class="dot relative inline-block size-9 text-3xl">
+        <i
+          v-for="index in 4"
+          :key="index"
+          class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
+        ></i>
+      </span>
+    </slot>
+
+    <div v-if="text" class="text-primary mt-4 text-xs">{{ text }}</div>
+    <slot></slot>
   </div>
 </template>
 

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

@@ -25,7 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
 });
 // const startTime = ref(0);
 const showSpinner = ref(false);
-const renderSpinner = ref(true);
+const renderSpinner = ref(false);
 const timer = ref<ReturnType<typeof setTimeout>>();
 
 watch(
@@ -74,6 +74,7 @@ function onTransitionEnd() {
   >
     <div
       :class="{ paused: !renderSpinner }"
+      v-if="renderSpinner"
       class="loader before:bg-primary/50 after:bg-primary relative size-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:content-['']"
     ></div>
   </div>

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

+ 1 - 1
packages/@core/ui-kit/tabs-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/tabs-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/constants/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/constants",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/effects/access/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/access",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 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';
 
@@ -86,8 +92,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;

+ 1 - 1
packages/effects/common-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/common-ui",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/effects/common-ui/src/components/count-to/count-to.vue

@@ -53,7 +53,7 @@ const numMain = computed(() => {
   const result = currentValue.value
     .toFixed(props.decimals)
     .split('.')[0]
-    ?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',');
+    ?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, props.separator);
   return result;
 });
 

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

@@ -5,6 +5,7 @@ export * from './count-to';
 export * from './ellipsis-text';
 export * from './icon-picker';
 export * from './json-viewer';
+export * from './loading';
 export * from './page';
 export * from './resize';
 export * from './tippy';
@@ -14,11 +15,15 @@ export * from '@vben-core/popup-ui';
 // 给文档用
 export {
   VbenButton,
+  VbenButtonGroup,
+  VbenCheckButtonGroup,
   VbenCountToAnimator,
   VbenInputPassword,
   VbenLoading,
   VbenPinInput,
   VbenSpinner,
+  VbenTree,
 } from '@vben-core/shadcn-ui';
 
+export type { FlattenedItem } from '@vben-core/shadcn-ui';
 export { globalShareState } from '@vben-core/shared/global-state';

+ 132 - 0
packages/effects/common-ui/src/components/loading/directive.ts

@@ -0,0 +1,132 @@
+import type { App, Directive, DirectiveBinding } from 'vue';
+
+import { h, render } from 'vue';
+
+import { VbenLoading, VbenSpinner } from '@vben-core/shadcn-ui';
+import { isString } from '@vben-core/shared/utils';
+
+const LOADING_INSTANCE_KEY = Symbol('loading');
+const SPINNER_INSTANCE_KEY = Symbol('spinner');
+
+const CLASS_NAME_RELATIVE = 'spinner-parent--relative';
+
+const loadingDirective: Directive = {
+  mounted(el, binding) {
+    const instance = h(VbenLoading, getOptions(binding));
+    render(instance, el);
+
+    el.classList.add(CLASS_NAME_RELATIVE);
+    el[LOADING_INSTANCE_KEY] = instance;
+  },
+  unmounted(el) {
+    const instance = el[LOADING_INSTANCE_KEY];
+    el.classList.remove(CLASS_NAME_RELATIVE);
+    render(null, el);
+    instance.el.remove();
+
+    el[LOADING_INSTANCE_KEY] = null;
+  },
+
+  updated(el, binding) {
+    const instance = el[LOADING_INSTANCE_KEY];
+    const options = getOptions(binding);
+    if (options && instance?.component) {
+      try {
+        Object.keys(options).forEach((key) => {
+          instance.component.props[key] = options[key];
+        });
+        instance.component.update();
+      } catch (error) {
+        console.error(
+          'Failed to update loading component in directive:',
+          error,
+        );
+      }
+    }
+  },
+};
+
+function getOptions(binding: DirectiveBinding) {
+  if (binding.value === undefined) {
+    return { spinning: true };
+  } else if (typeof binding.value === 'boolean') {
+    return { spinning: binding.value };
+  } else {
+    return { ...binding.value };
+  }
+}
+
+const spinningDirective: Directive = {
+  mounted(el, binding) {
+    const instance = h(VbenSpinner, getOptions(binding));
+    render(instance, el);
+
+    el.classList.add(CLASS_NAME_RELATIVE);
+    el[SPINNER_INSTANCE_KEY] = instance;
+  },
+  unmounted(el) {
+    const instance = el[SPINNER_INSTANCE_KEY];
+    el.classList.remove(CLASS_NAME_RELATIVE);
+    render(null, el);
+    instance.el.remove();
+
+    el[SPINNER_INSTANCE_KEY] = null;
+  },
+
+  updated(el, binding) {
+    const instance = el[SPINNER_INSTANCE_KEY];
+    const options = getOptions(binding);
+    if (options && instance?.component) {
+      try {
+        Object.keys(options).forEach((key) => {
+          instance.component.props[key] = options[key];
+        });
+        instance.component.update();
+      } catch (error) {
+        console.error(
+          'Failed to update spinner component in directive:',
+          error,
+        );
+      }
+    }
+  },
+};
+
+type loadingDirectiveParams = {
+  /** 是否注册loading指令。如果提供一个string,则将指令注册为指定的名称 */
+  loading?: boolean | string;
+  /** 是否注册spinning指令。如果提供一个string,则将指令注册为指定的名称 */
+  spinning?: boolean | string;
+};
+
+/**
+ * 注册loading指令
+ * @param app
+ * @param params
+ */
+export function registerLoadingDirective(
+  app: App,
+  params?: loadingDirectiveParams,
+) {
+  // 注入一个样式供指令使用,确保容器是相对定位
+  const style = document.createElement('style');
+  style.id = CLASS_NAME_RELATIVE;
+  style.innerHTML = `
+    .${CLASS_NAME_RELATIVE} {
+      position: relative !important;
+    }
+  `;
+  document.head.append(style);
+  if (params?.loading !== false) {
+    app.directive(
+      isString(params?.loading) ? params.loading : 'loading',
+      loadingDirective,
+    );
+  }
+  if (params?.spinning !== false) {
+    app.directive(
+      isString(params?.spinning) ? params.spinning : 'spinning',
+      spinningDirective,
+    );
+  }
+}

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

@@ -0,0 +1,3 @@
+export * from './directive';
+export { default as Loading } from './loading.vue';
+export { default as Spinner } from './spinner.vue';

+ 39 - 0
packages/effects/common-ui/src/components/loading/loading.vue

@@ -0,0 +1,39 @@
+<script lang="ts" setup>
+import { VbenLoading } from '@vben-core/shadcn-ui';
+import { cn } from '@vben-core/shared/utils';
+
+interface LoadingProps {
+  class?: string;
+  /**
+   * @zh_CN 最小加载时间
+   * @en_US Minimum loading time
+   */
+  minLoadingTime?: number;
+
+  /**
+   * @zh_CN loading状态开启
+   */
+  spinning?: boolean;
+  /**
+   * @zh_CN 文字
+   */
+  text?: string;
+}
+
+defineOptions({ name: 'Loading' });
+const props = defineProps<LoadingProps>();
+</script>
+<template>
+  <div :class="cn('relative min-h-20', props.class)">
+    <slot></slot>
+    <VbenLoading
+      :min-loading-time="props.minLoadingTime"
+      :spinning="props.spinning"
+      :text="props.text"
+    >
+      <template v-if="$slots.icon" #icon>
+        <slot name="icon"></slot>
+      </template>
+    </VbenLoading>
+  </div>
+</template>

+ 28 - 0
packages/effects/common-ui/src/components/loading/spinner.vue

@@ -0,0 +1,28 @@
+<script lang="ts" setup>
+import { VbenSpinner } from '@vben-core/shadcn-ui';
+import { cn } from '@vben-core/shared/utils';
+
+interface SpinnerProps {
+  class?: string;
+  /**
+   * @zh_CN 最小加载时间
+   * @en_US Minimum loading time
+   */
+  minLoadingTime?: number;
+  /**
+   * @zh_CN loading状态开启
+   */
+  spinning?: boolean;
+}
+defineOptions({ name: 'Spinner' });
+const props = defineProps<SpinnerProps>();
+</script>
+<template>
+  <div :class="cn('relative min-h-20', props.class)">
+    <slot></slot>
+    <VbenSpinner
+      :min-loading-time="props.minLoadingTime"
+      :spinning="props.spinning"
+    />
+  </div>
+</template>

+ 1 - 4
packages/effects/common-ui/src/ui/authentication/code-login.vue

@@ -70,10 +70,7 @@ async function handleSubmit() {
   const { valid } = await formApi.validate();
   const values = await formApi.getValues();
   if (valid) {
-    emit('submit', {
-      code: values?.code,
-      phoneNumber: values?.phoneNumber,
-    });
+    emit('submit', values);
   }
 }
 

+ 1 - 1
packages/effects/hooks/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/hooks",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/effects/layouts/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/layouts",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/effects/plugins/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/plugins",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 25 - 4
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,13 +353,25 @@ 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>
         <div
           v-if="formOptions"
           v-show="showSearchForm !== false"
-          :class="cn('relative rounded py-3', isCompactForm ? 'pb-6' : 'pb-4')"
+          :class="cn('relative rounded py-3', isCompactForm ? 'pb-8' : 'pb-4')"
         >
           <slot name="form">
             <Form>

+ 1 - 1
packages/effects/request/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/request",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/icons/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/icons",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/locales/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/locales",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 2 - 0
packages/locales/src/index.ts

@@ -7,9 +7,11 @@ import {
 } from './i18n';
 
 const $t = i18n.global.t;
+const $te = i18n.global.te;
 
 export {
   $t,
+  $te,
   i18n,
   loadLocaleMessages,
   loadLocalesMap,

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

@@ -6,9 +6,17 @@
   "prompt": "Prompt",
   "cancel": "Cancel",
   "confirm": "Confirm",
+  "reset": "Reset",
   "noData": "No Data",
   "refresh": "Refresh",
   "loadingMenu": "Loading Menu",
   "query": "Search",
-  "search": "Search"
+  "search": "Search",
+  "enabled": "Enabled",
+  "disabled": "Disabled",
+  "edit": "Edit",
+  "delete": "Delete",
+  "create": "Create",
+  "yes": "Yes",
+  "no": "No"
 }

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

@@ -1,7 +1,26 @@
 {
   "formRules": {
     "required": "Please enter {0}",
-    "selectRequired": "Please select {0}"
+    "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",
+    "alreadyExists": "{0} `{1}` already exists",
+    "startWith": "{0} must start with `{1}`",
+    "invalidURL": "Please input a valid URL"
+  },
+  "actionTitle": {
+    "edit": "Modify {0}",
+    "create": "Create {0}",
+    "delete": "Delete {0}",
+    "view": "View {0}"
+  },
+  "actionMessage": {
+    "deleteConfirm": "Are you sure to delete {0}?",
+    "deleting": "Deleting {0} ...",
+    "deleteSuccess": "{0} deleted successfully",
+    "operationSuccess": "Operation succeeded",
+    "operationFailed": "Operation failed"
   },
   "placeholder": {
     "input": "Please enter",

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

@@ -6,9 +6,17 @@
   "prompt": "提示",
   "cancel": "取消",
   "confirm": "确认",
+  "reset": "重置",
   "noData": "暂无数据",
   "refresh": "刷新",
   "loadingMenu": "加载菜单中",
   "query": "查询",
-  "search": "搜索"
+  "search": "搜索",
+  "enabled": "已启用",
+  "disabled": "已禁用",
+  "edit": "修改",
+  "delete": "删除",
+  "create": "新增",
+  "yes": "是",
+  "no": "否"
 }

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

@@ -1,7 +1,26 @@
 {
   "formRules": {
     "required": "请输入{0}",
-    "selectRequired": "请选择{0}"
+    "selectRequired": "请选择{0}",
+    "minLength": "{0}至少{1}个字符",
+    "maxLength": "{0}最多{1}个字符",
+    "length": "{0}长度必须为{1}个字符",
+    "alreadyExists": "{0} `{1}` 已存在",
+    "startWith": "{0}必须以 {1} 开头",
+    "invalidURL": "请输入有效的链接"
+  },
+  "actionTitle": {
+    "edit": "修改{0}",
+    "create": "新增{0}",
+    "delete": "删除{0}",
+    "view": "查看{0}"
+  },
+  "actionMessage": {
+    "deleteConfirm": "确定删除 {0} 吗?",
+    "deleting": "正在删除 {0} ...",
+    "deleteSuccess": "{0} 删除成功",
+    "operationSuccess": "操作成功",
+    "operationFailed": "操作失败"
   },
   "placeholder": {
     "input": "请输入",

+ 1 - 1
packages/preferences/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/preferences",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/stores/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/stores",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/styles/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/styles",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/types/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/types",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 1 - 1
packages/utils/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vben/utils",
-  "version": "5.5.3",
+  "version": "5.5.4",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

+ 8 - 6
packages/utils/src/helpers/generate-routes-backend.ts

@@ -100,12 +100,14 @@ function convertRoutes(
       // 页面组件转换
     } else if (component) {
       const normalizePath = normalizeViewPath(component);
-      route.component =
-        pageMap[
-          normalizePath.endsWith('.vue')
-            ? normalizePath
-            : `${normalizePath}.vue`
-        ];
+      const pageKey = normalizePath.endsWith('.vue')
+        ? normalizePath
+        : `${normalizePath}.vue`;
+      if (pageMap[pageKey]) {
+        route.component = pageMap[pageKey];
+      } else {
+        console.error(`route component is invalid: ${pageKey}`, route);
+      }
     }
 
     return route;

+ 6 - 2
packages/utils/src/helpers/get-popup-container.ts

@@ -1,6 +1,10 @@
 /**
- * Returns the parent node of the given element or the document body if the element is not provided.it
+ * If the node is holding inside a form, return the form element,
+ * otherwise return the parent node of the given element or
+ * the document body if the element is not provided.
  */
 export function getPopupContainer(node?: HTMLElement): HTMLElement {
-  return (node?.parentNode as HTMLElement) ?? document.body;
+  return (
+    node?.closest('form') ?? (node?.parentNode as HTMLElement) ?? document.body
+  );
 }

+ 54 - 0
playground/src/api/system/dept.ts

@@ -0,0 +1,54 @@
+import { requestClient } from '#/api/request';
+
+export namespace SystemDeptApi {
+  export interface SystemDept {
+    [key: string]: any;
+    children?: SystemDept[];
+    id: string;
+    name: string;
+    remark?: string;
+    status: 0 | 1;
+  }
+}
+
+/**
+ * 获取部门列表数据
+ */
+async function getDeptList() {
+  return requestClient.get<Array<SystemDeptApi.SystemDept>>(
+    '/system/dept/list',
+  );
+}
+
+/**
+ * 创建部门
+ * @param data 部门数据
+ */
+async function createDept(
+  data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
+) {
+  return requestClient.post('/system/dept', data);
+}
+
+/**
+ * 更新部门
+ *
+ * @param id 部门 ID
+ * @param data 部门数据
+ */
+async function updateDept(
+  id: string,
+  data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
+) {
+  return requestClient.put(`/system/dept/${id}`, data);
+}
+
+/**
+ * 删除部门
+ * @param id 部门 ID
+ */
+async function deleteDept(id: string) {
+  return requestClient.delete(`/system/dept/${id}`);
+}
+
+export { createDept, deleteDept, getDeptList, updateDept };

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

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

@@ -0,0 +1,65 @@
+{
+  "title": "System Management",
+  "dept": {
+    "name": "Department",
+    "title": "Department Management",
+    "deptName": "Department Name",
+    "status": "Status",
+    "createTime": "Create Time",
+    "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"
+  }
+}

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

@@ -0,0 +1,67 @@
+{
+  "dept": {
+    "list": "部门列表",
+    "createTime": "创建时间",
+    "deptName": "部门名称",
+    "name": "部门",
+    "operation": "操作",
+    "parentDept": "上级部门",
+    "remark": "备注",
+    "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": "系统管理"
+}

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

@@ -0,0 +1,46 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+  {
+    meta: {
+      icon: 'ion:settings-outline',
+      order: 9997,
+      title: $t('system.title'),
+    },
+    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',
+        meta: {
+          icon: 'charm:organisation',
+          title: $t('system.dept.title'),
+        },
+        component: () => import('#/views/system/dept/list.vue'),
+      },
+    ],
+  },
+];
+
+export default routes;

+ 194 - 0
playground/src/views/examples/button-group/index.vue

@@ -0,0 +1,194 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import { reactive, ref } from 'vue';
+
+import {
+  Page,
+  VbenButton,
+  VbenButtonGroup,
+  VbenCheckButtonGroup,
+} from '@vben/common-ui';
+
+import { Button, Card, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+
+const radioValue = ref<string | undefined>('a');
+const checkValue = ref(['a', 'b']);
+
+const options = [
+  { label: '选项1', value: 'a' },
+  { label: '选项2', value: 'b' },
+  { label: '选项3', value: 'c' },
+  { label: '选项4', value: 'd' },
+  { label: '选项5', value: 'e' },
+  { label: '选项6', value: 'f' },
+];
+
+function resetValues() {
+  radioValue.value = undefined;
+  checkValue.value = [];
+}
+
+function beforeChange(v: any, isChecked: boolean) {
+  return new Promise((resolve) => {
+    message.loading({
+      content: `正在设置${v}为${isChecked ? '选中' : '未选中'}...`,
+      duration: 0,
+      key: 'beforeChange',
+    });
+    setTimeout(() => {
+      message.success({ content: `${v} 已设置成功`, key: 'beforeChange' });
+      resolve(true);
+    }, 2000);
+  });
+}
+
+const compProps = reactive({
+  beforeChange: undefined,
+  disabled: false,
+  gap: 0,
+  showIcon: true,
+  size: 'middle',
+} as Recordable<any>);
+
+const [Form] = useVbenForm({
+  handleValuesChange(values) {
+    Object.keys(values).forEach((k) => {
+      if (k === 'beforeChange') {
+        compProps[k] = values[k] ? beforeChange : undefined;
+      } else {
+        compProps[k] = values[k];
+      }
+    });
+  },
+  schema: [
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '大', value: 'large' },
+          { label: '中', value: 'middle' },
+          { label: '小', value: 'small' },
+        ],
+      },
+      defaultValue: compProps.size,
+      fieldName: 'size',
+      label: '尺寸',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        options: [
+          { label: '无', value: 0 },
+          { label: '小', value: 5 },
+          { label: '中', value: 15 },
+          { label: '大', value: 30 },
+        ],
+      },
+      defaultValue: compProps.gap,
+      fieldName: 'gap',
+      label: '间距',
+    },
+    {
+      component: 'Switch',
+      defaultValue: compProps.showIcon,
+      fieldName: 'showIcon',
+      label: '显示图标',
+    },
+    {
+      component: 'Switch',
+      defaultValue: compProps.disabled,
+      fieldName: 'disabled',
+      label: '禁用',
+    },
+    {
+      component: 'Switch',
+      defaultValue: false,
+      fieldName: 'beforeChange',
+      label: '前置回调',
+    },
+  ],
+  showDefaultActions: false,
+  submitOnChange: true,
+});
+
+function onBtnClick(value: any) {
+  const opt = options.find((o) => o.value === value);
+  if (opt) {
+    message.success(`点击了按钮${opt.label},value = ${value}`);
+  }
+}
+</script>
+<template>
+  <Page
+    title="VbenButtonGroup 按钮组"
+    description="VbenButtonGroup是一个按钮容器,用于包裹一组按钮,协调整体样式。VbenCheckButtonGroup则可以作为一个表单组件,提供单选或多选功能"
+  >
+    <Card title="基本用法">
+      <template #extra>
+        <Button type="primary" @click="resetValues">清空值</Button>
+      </template>
+      <p class="mt-4">按钮组:</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenButtonGroup v-bind="compProps" border>
+          <VbenButton
+            v-for="btn in options"
+            :key="btn.value"
+            variant="link"
+            @click="onBtnClick(btn.value)"
+          >
+            {{ btn.label }}
+          </VbenButton>
+        </VbenButtonGroup>
+        <VbenButtonGroup v-bind="compProps" border>
+          <VbenButton
+            v-for="btn in options"
+            :key="btn.value"
+            variant="outline"
+            @click="onBtnClick(btn.value)"
+          >
+            {{ btn.label }}
+          </VbenButton>
+        </VbenButtonGroup>
+      </div>
+      <p class="mt-4">单选:{{ radioValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="radioValue"
+          :options="options"
+          v-bind="compProps"
+        />
+      </div>
+      <p class="mt-4">单选插槽:{{ radioValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="radioValue"
+          :options="options"
+          v-bind="compProps"
+        >
+          <template #option="{ label, value }">
+            <div class="flex items-center">
+              <span>{{ label }}</span>
+              <span class="ml-2 text-gray-400">{{ value }}</span>
+            </div>
+          </template>
+        </VbenCheckButtonGroup>
+      </div>
+      <p class="mt-4">多选{{ checkValue }}</p>
+      <div class="mt-2 flex flex-col gap-2">
+        <VbenCheckButtonGroup
+          v-model="checkValue"
+          multiple
+          :options="options"
+          v-bind="compProps"
+        />
+      </div>
+    </Card>
+
+    <Card title="设置" class="mt-4">
+      <Form />
+    </Card>
+  </Page>
+</template>

+ 111 - 0
playground/src/views/examples/form/custom-layout.vue

@@ -0,0 +1,111 @@
+<script lang="ts" setup>
+import { h } from 'vue';
+
+import { Page } from '@vben/common-ui';
+
+import { Card } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+
+import DocButton from '../doc-button.vue';
+
+const [CustomLayoutForm] = useVbenForm({
+  // 所有表单项共用,可单独在表单内覆盖
+  commonConfig: {
+    // 所有表单项
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  layout: 'horizontal',
+  schema: [
+    {
+      component: 'Select',
+      fieldName: 'field1',
+      label: '字符串',
+    },
+    {
+      component: 'TreeSelect',
+      fieldName: 'field2',
+      label: '字符串',
+    },
+    {
+      component: 'Mentions',
+      fieldName: 'field3',
+      label: '字符串',
+    },
+    {
+      component: 'Input',
+      fieldName: 'field4',
+      label: '字符串',
+    },
+    {
+      component: 'InputNumber',
+      fieldName: 'field5',
+      // 从第三列开始 相当于中间空了一列
+      formItemClass: 'col-start-3',
+      label: '前面空了一列',
+    },
+    {
+      component: 'Divider',
+      fieldName: '_divider',
+      formItemClass: 'col-span-3',
+      hideLabel: true,
+      renderComponentContent: () => {
+        return {
+          default: () => h('div', '分割线'),
+        };
+      },
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'field6',
+      // 占满三列空间 基线对齐
+      formItemClass: 'col-span-3 items-baseline',
+      label: '占满三列',
+    },
+    {
+      component: 'Input',
+      fieldName: 'field7',
+      // 占满2列空间 从第二列开始 相当于前面空了一列
+      formItemClass: 'col-span-2 col-start-2',
+      label: '占满2列',
+    },
+    {
+      component: 'Input',
+      fieldName: 'field8',
+      // 左右留空
+      formItemClass: 'col-start-2',
+      label: '左右留空',
+    },
+    {
+      component: 'InputPassword',
+      fieldName: 'field9',
+      formItemClass: 'col-start-1',
+      label: '字符串',
+    },
+  ],
+  // 一共三列
+  wrapperClass: 'grid-cols-3',
+});
+</script>
+
+<template>
+  <Page
+    content-class="flex flex-col gap-4"
+    description="使用tailwind自定义表单项的布局"
+    title="表单自定义布局"
+  >
+    <template #description>
+      <div class="text-muted-foreground">
+        <p>使用tailwind自定义表单项的布局,使用Divider分割表单。</p>
+      </div>
+    </template>
+    <template #extra>
+      <DocButton class="mb-2" path="/components/common-ui/vben-form" />
+    </template>
+    <Card title="使用tailwind自定义布局">
+      <CustomLayoutForm />
+    </Card>
+  </Page>
+</template>

+ 101 - 0
playground/src/views/examples/loading/index.vue

@@ -0,0 +1,101 @@
+<script lang="ts" setup>
+import { Loading, Page, Spinner } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { refAutoReset } from '@vueuse/core';
+import { Button, Card, Spin } from 'ant-design-vue';
+
+const spinning = refAutoReset(false, 3000);
+const loading = refAutoReset(false, 3000);
+
+const spinningV = refAutoReset(false, 3000);
+const loadingV = refAutoReset(false, 3000);
+</script>
+<template>
+  <Page
+    title="Vben Loading"
+    description="加载中状态组件。这个组件可以为其它作为容器的组件添加一个加载中的遮罩层。使用它们时,容器需要relative定位。"
+  >
+    <Card title="Antd Spin">
+      <template #actions>这是Antd 组件库自带的Spin组件演示</template>
+      <Spin :spinning="spinning" tip="加载中...">
+        <Button type="primary" @click="spinning = true">显示Spin</Button>
+      </Spin>
+    </Card>
+
+    <Card title="Vben Loading" v-loading="loadingV" class="mt-4">
+      <template #extra>
+        <Button type="primary" @click="loadingV = true">
+          v-loading 指令
+        </Button>
+      </template>
+      <template #actions>
+        Loading组件可以设置文字,并且也提供了icon插槽用于替换加载图标。
+      </template>
+      <div class="flex gap-4">
+        <div class="size-40">
+          <Loading
+            :spinning="loading"
+            text="正在加载..."
+            class="flex h-full w-full items-center justify-center"
+          >
+            <Button type="primary" @click="loading = true">默认动画</Button>
+          </Loading>
+        </div>
+        <div class="size-40">
+          <Loading
+            :spinning="loading"
+            class="flex h-full w-full items-center justify-center"
+          >
+            <Button type="primary" @click="loading = true">自定义动画1</Button>
+            <template #icon>
+              <IconifyIcon
+                icon="svg-spinners:ring-resize"
+                class="text-primary size-10"
+              />
+            </template>
+          </Loading>
+        </div>
+        <div class="size-40">
+          <Loading
+            :spinning="loading"
+            class="flex h-full w-full items-center justify-center"
+          >
+            <Button type="primary" @click="loading = true">自定义动画2</Button>
+            <template #icon>
+              <IconifyIcon
+                icon="svg-spinners:bars-scale"
+                class="text-primary size-10"
+              />
+            </template>
+          </Loading>
+        </div>
+      </div>
+    </Card>
+
+    <Card
+      title="Vben Spinner"
+      v-spinning="spinningV"
+      class="mt-4 overflow-hidden"
+      :body-style="{
+        position: 'relative',
+        overflow: 'hidden',
+      }"
+    >
+      <template #extra>
+        <Button type="primary" @click="spinningV = true">
+          v-spinning 指令
+        </Button>
+      </template>
+      <template #actions>
+        Spinner组件是Loading组件的一个特例,只有一个固定的统一样式。
+      </template>
+      <Spinner
+        :spinning="spinning"
+        class="flex size-40 items-center justify-center"
+      >
+        <Button type="primary" @click="spinning = true">显示Spinner</Button>
+      </Spinner>
+    </Card>
+  </Page>
+</template>

+ 135 - 0
playground/src/views/system/dept/data.ts

@@ -0,0 +1,135 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { SystemDeptApi } from '#/api/system/dept';
+
+import { z } from '#/adapter/form';
+import { getDeptList } from '#/api/system/dept';
+import { $t } from '#/locales';
+
+/**
+ * 获取编辑表单的字段配置。如果没有使用多语言,可以直接export一个数组常量
+ */
+export function useSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.dept.deptName'),
+      rules: z
+        .string()
+        .min(2, $t('ui.formRules.minLength', [$t('system.dept.deptName'), 2]))
+        .max(
+          20,
+          $t('ui.formRules.maxLength', [$t('system.dept.deptName'), 20]),
+        ),
+    },
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        allowClear: true,
+        api: getDeptList,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+      },
+      fieldName: 'pid',
+      label: $t('system.dept.parentDept'),
+    },
+    {
+      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.dept.status'),
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        maxLength: 50,
+        rows: 3,
+        showCount: true,
+      },
+      fieldName: 'remark',
+      label: $t('system.dept.remark'),
+      rules: z
+        .string()
+        .max(50, $t('ui.formRules.maxLength', [$t('system.dept.remark'), 50]))
+        .optional(),
+    },
+  ];
+}
+
+/**
+ * 获取表格列配置
+ * @description 使用函数的形式返回列数据而不是直接export一个Array常量,是为了响应语言切换时重新翻译表头
+ * @param onActionClick 表格操作按钮点击事件
+ */
+export function useColumns(
+  onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
+): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
+  return [
+    {
+      align: 'left',
+      field: 'name',
+      fixed: 'left',
+      title: $t('system.dept.deptName'),
+      treeNode: true,
+      width: 150,
+    },
+    {
+      cellRender: { name: 'CellTag' },
+      field: 'status',
+      title: $t('system.dept.status'),
+      width: 100,
+    },
+    {
+      field: 'createTime',
+      title: $t('system.dept.createTime'),
+      width: 180,
+    },
+    {
+      field: 'remark',
+      title: $t('system.dept.remark'),
+    },
+    {
+      align: 'right',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.dept.name'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+        options: [
+          {
+            code: 'append',
+            text: '新增下级',
+          },
+          'edit', // 默认的编辑按钮
+          {
+            code: 'delete', // 默认的删除按钮
+            disabled: (row: SystemDeptApi.SystemDept) => {
+              return !!(row.children && row.children.length > 0);
+            },
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: $t('system.dept.operation'),
+      width: 200,
+    },
+  ];
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio