Browse Source

Merge branch 'vbenjs:main' into main

pull/8367/head
失忆 2 months ago
committed by GitHub
parent
commit
a6366e47d5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      apps/backend-mock/routes/[...].ts
  2. 24
      apps/backend-mock/utils/mock-data.ts
  3. 1
      apps/web-antd/src/adapter/component/index.ts
  4. 4
      apps/web-antd/src/router/routes/modules/dashboard.ts
  5. 2
      apps/web-antd/src/router/routes/modules/demos.ts
  6. 12
      apps/web-antd/src/router/routes/modules/vben.ts
  7. 1
      apps/web-antdv-next/src/adapter/component/index.ts
  8. 4
      apps/web-antdv-next/src/router/routes/modules/dashboard.ts
  9. 2
      apps/web-antdv-next/src/router/routes/modules/demos.ts
  10. 12
      apps/web-antdv-next/src/router/routes/modules/vben.ts
  11. 4
      apps/web-ele/src/router/routes/modules/dashboard.ts
  12. 4
      apps/web-ele/src/router/routes/modules/demos.ts
  13. 12
      apps/web-ele/src/router/routes/modules/vben.ts
  14. 4
      apps/web-naive/src/router/routes/modules/dashboard.ts
  15. 8
      apps/web-naive/src/router/routes/modules/demos.ts
  16. 12
      apps/web-naive/src/router/routes/modules/vben.ts
  17. 4
      apps/web-tdesign/src/router/routes/modules/dashboard.ts
  18. 2
      apps/web-tdesign/src/router/routes/modules/demos.ts
  19. 12
      apps/web-tdesign/src/router/routes/modules/vben.ts
  20. 40
      docs/src/en/guide/essentials/route.md
  21. 18
      docs/src/en/guide/essentials/server.md
  22. 4
      docs/src/en/guide/essentials/settings.md
  23. 2
      docs/src/en/index.md
  24. 37
      docs/src/guide/essentials/route.md
  25. 18
      docs/src/guide/essentials/server.md
  26. 4
      docs/src/guide/essentials/settings.md
  27. 2
      docs/src/index.md
  28. 5
      internal/lint-configs/eslint-config/src/configs/javascript.ts
  29. 10
      internal/lint-configs/oxlint-config/src/configs/javascript.ts
  30. 1
      internal/lint-configs/oxlint-config/src/configs/vue.ts
  31. 8
      internal/node-utils/src/git.ts
  32. 12
      packages/@core/base/shared/src/utils/tree.ts
  33. 1
      packages/@core/base/typings/src/basic.d.ts
  34. 4
      packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap
  35. 2
      packages/@core/preferences/src/config.ts
  36. 70
      packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
  37. 182
      packages/@core/ui-kit/form-ui/src/components/form-field-array.vue
  38. 233
      packages/@core/ui-kit/form-ui/src/form-api.ts
  39. 7
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  40. 101
      packages/@core/ui-kit/form-ui/src/form-render/form.vue
  41. 341
      packages/@core/ui-kit/form-ui/src/form-render/schema.ts
  42. 1
      packages/@core/ui-kit/form-ui/src/index.ts
  43. 57
      packages/@core/ui-kit/form-ui/src/types.ts
  44. 2
      packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
  45. 12
      packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue
  46. 4
      packages/@core/ui-kit/shadcn-ui/src/components/segmented/tabs-indicator.vue
  47. 2
      packages/@core/ui-kit/shadcn-ui/src/ui/tabs/TabsList.vue
  48. 10
      packages/@core/ui-kit/shadcn-ui/src/ui/tooltip/TooltipContent.vue
  49. 240
      packages/effects/access/src/__tests__/accessible.test.ts
  50. 30
      packages/effects/access/src/accessible.ts
  51. 1
      packages/effects/layouts/src/basic/content/content.vue
  52. 39
      packages/effects/layouts/src/basic/header/header.vue
  53. 2
      packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue
  54. 26
      packages/stores/src/modules/user.ts
  55. 1
      playground/src/adapter/component/index.ts
  56. 4
      playground/src/router/routes/modules/dashboard.ts
  57. 110
      playground/src/router/routes/modules/demos.ts
  58. 93
      playground/src/router/routes/modules/examples.ts
  59. 8
      playground/src/router/routes/modules/system.ts
  60. 14
      playground/src/router/routes/modules/vben.ts
  61. 325
      playground/src/views/demos/form-array/README.md
  62. 187
      playground/src/views/demos/form-array/index.vue

10
apps/backend-mock/routes/[...].ts

@ -3,13 +3,13 @@ import { defineEventHandler } from 'h3';
export default defineEventHandler(() => {
return `
<h1>Hello Vben Admin</h1>
<h2>Mock service is starting</h2>
<h2>Mock service is running</h2>
<ul>
<li><a href="/api/user">/api/user/info</a></li>
<li><a href="/api/menu">/api/menu/all</a></li>
<li><a href="/api/auth/codes">/api/auth/codes</a></li>
<li><a href="/api/auth/login">/api/auth/login</a></li>
<li><a href="/api/upload">/api/upload</a></li>
<li><a href="/api/auth/codes">/api/auth/codes</a></li>
<li><a href="/api/user/info">/api/user/info</a></li>
<li><a href="/api/menu/all">/api/menu/all</a></li>
<li><a href="/api/auth/logout">/api/auth/logout</a></li>
</ul>
`;
});

24
apps/backend-mock/utils/mock-data.ts

@ -26,7 +26,7 @@ export const MOCK_USERS: UserInfo[] = [
realName: 'Admin',
roles: ['admin'],
username: 'admin',
homePath: '/workspace',
homePath: '/dashboard/workspace',
},
{
id: 2,
@ -34,7 +34,7 @@ export const MOCK_USERS: UserInfo[] = [
realName: 'Jack',
roles: ['user'],
username: 'jack',
homePath: '/analytics',
homePath: '/dashboard/analytics',
},
];
@ -64,11 +64,11 @@ const dashboardMenus = [
},
name: 'Dashboard',
path: '/dashboard',
redirect: '/analytics',
redirect: '/dashboard/analytics',
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
@ -77,7 +77,7 @@ const dashboardMenus = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: '/dashboard/workspace/index',
meta: {
title: 'page.dashboard.workspace',
@ -96,7 +96,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
title: 'demos.access.adminVisible',
},
name: 'AccessAdminVisibleDemo',
path: '/demos/access/admin-visible',
path: 'admin-visible',
},
super: {
component: '/demos/access/super-visible',
@ -105,7 +105,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
title: 'demos.access.superVisible',
},
name: 'AccessSuperVisibleDemo',
path: '/demos/access/super-visible',
path: 'super-visible',
},
user: {
component: '/demos/access/user-visible',
@ -114,7 +114,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
title: 'demos.access.userVisible',
},
name: 'AccessUserVisibleDemo',
path: '/demos/access/user-visible',
path: 'user-visible',
},
};
@ -132,7 +132,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
children: [
{
name: 'AccessDemos',
path: '/demosaccess',
path: 'access',
meta: {
icon: 'mdi:cloud-key-outline',
title: 'demos.access.backendPermissions',
@ -141,7 +141,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
children: [
{
name: 'AccessPageControlDemo',
path: '/demos/access/page-control',
path: 'page-control',
component: '/demos/access/index',
meta: {
icon: 'mdi:page-previous-outline',
@ -150,7 +150,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
},
{
name: 'AccessButtonControlDemo',
path: '/demos/access/button-control',
path: 'button-control',
component: '/demos/access/button-control',
meta: {
icon: 'mdi:button-cursor',
@ -159,7 +159,7 @@ const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
},
{
name: 'AccessMenuVisible403Demo',
path: '/demos/access/menu-visible-403',
path: 'menu-visible-403',
component: '/demos/access/menu-visible-403',
meta: {
authority: ['no-body'],

1
apps/web-antd/src/adapter/component/index.ts

@ -360,6 +360,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

4
apps/web-antd/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

2
apps/web-antd/src/router/routes/modules/demos.ts

@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.antd'),
},
name: 'AntDesignDemos',
path: '/demos/ant-design',
path: 'ant-design',
component: () => import('#/views/demos/antd/index.vue'),
},
],

12
apps/web-antd/src/router/routes/modules/vben.ts

@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -37,7 +37,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdVNext',
path: '/vben-admin/antdv-next',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -69,7 +69,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -80,7 +80,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',

1
apps/web-antdv-next/src/adapter/component/index.ts

@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

4
apps/web-antdv-next/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

2
apps/web-antdv-next/src/router/routes/modules/demos.ts

@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.antd'),
},
name: 'AntDesignDemos',
path: '/demos/ant-design-next',
path: 'ant-design-next',
component: () => import('#/views/demos/antd/index.vue'),
},
],

12
apps/web-antdv-next/src/router/routes/modules/vben.ts

@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -37,7 +37,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntd',
path: '/vben-admin/antd',
path: 'antd',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -69,7 +69,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -80,7 +80,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',

4
apps/web-ele/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

4
apps/web-ele/src/router/routes/modules/demos.ts

@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.elementPlus'),
},
name: 'NaiveDemos',
path: '/demos/element',
path: 'element',
component: () => import('#/views/demos/element/index.vue'),
},
{
@ -26,7 +26,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.form'),
},
name: 'BasicForm',
path: '/demos/form',
path: 'form',
component: () => import('#/views/demos/form/basic.vue'),
},
],

12
apps/web-ele/src/router/routes/modules/vben.ts

@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -41,7 +41,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -51,7 +51,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -62,7 +62,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntd',
path: '/vben-admin/antd',
path: 'antd',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -73,7 +73,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdVNext',
path: '/vben-admin/antdv-next',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -84,7 +84,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',

4
apps/web-naive/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

8
apps/web-naive/src/router/routes/modules/demos.ts

@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.naive'),
},
name: 'NaiveDemos',
path: '/demos/naive',
path: 'naive',
component: () => import('#/views/demos/naive/index.vue'),
},
{
@ -26,7 +26,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.table'),
},
name: 'Table',
path: '/demos/table',
path: 'table',
component: () => import('#/views/demos/table/index.vue'),
},
{
@ -34,7 +34,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.form'),
},
name: 'Form',
path: '/demos/form',
path: 'form',
component: () => import('#/views/demos/form/basic.vue'),
},
{
@ -42,7 +42,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.arrayForm'),
},
name: 'ArrayForm',
path: '/demos/array-form',
path: 'array-form',
component: () => import('#/views/demos/naive/array-form/index.vue'),
},
],

12
apps/web-naive/src/router/routes/modules/vben.ts

@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -41,7 +41,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -51,7 +51,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntd',
path: '/vben-admin/antd',
path: 'antd',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -62,7 +62,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdVNext',
path: '/vben-admin/antdv-next',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -73,7 +73,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -84,7 +84,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',

4
apps/web-tdesign/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

2
apps/web-tdesign/src/router/routes/modules/demos.ts

@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.tdesign'),
},
name: 'TDesignDemos',
path: '/demos/tdesign',
path: 'tdesign',
component: () => import('#/views/demos/tdesign/index.vue'),
},
],

12
apps/web-tdesign/src/router/routes/modules/vben.ts

@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -37,7 +37,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdv',
path: '/vben-admin/antdv',
path: 'antdv',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -69,7 +69,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdVNext',
path: '/vben-admin/antdv-next',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -80,7 +80,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',

40
docs/src/en/guide/essentials/route.md

@ -61,6 +61,13 @@ The configuration method of static routes and dynamic routes is the same. Below
### Secondary Routes
::: tip
- Only top-level routes should start their `path` with `/`; routes inside `children` should use relative segments such as `about` or `menu1`.
- When a parent route does not define `redirect`, the framework automatically fills it with the first child route. This only works when child paths stay relative.
:::
::: details Secondary Route Example Code
```ts
@ -68,7 +75,6 @@ import type { RouteRecordRaw } from 'vue-router';
import { VBEN_LOGO_URL } from '@vben/constants';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
@ -82,11 +88,10 @@ const routes: RouteRecordRaw[] = [
},
name: 'VbenProject',
path: '/vben-admin',
redirect: '/vben-admin/about',
children: [
{
name: 'VbenAbout',
path: '/vben-admin/about',
path: 'about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
badgeType: 'dot',
@ -108,6 +113,7 @@ export default routes;
::: tip
- Multi-level routes follow the same rule: keep top-level paths absolute and child paths relative, instead of repeating the full path inside `children`.
- The parent route of multi-level routes does not need to set the `component` property, just set the `children` property. Unless you really need to display content nested under the parent route.
- In most cases, the `redirect` property of the parent route does not need to be specified, it will default to the first child route.
@ -118,7 +124,6 @@ export default routes;
```ts
import type { RouteRecordRaw } from 'vue-router';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
@ -131,7 +136,6 @@ const routes: RouteRecordRaw[] = [
},
name: 'Demos',
path: '/demos',
redirect: '/demos/access',
children: [
// Nested menu
{
@ -140,12 +144,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.nested.title'),
},
name: 'NestedDemos',
path: '/demos/nested',
redirect: '/demos/nested/menu1',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: '/demos/nested/menu1',
path: 'menu1',
component: () => import('#/views/demos/nested/menu-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -155,17 +158,16 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu2Demo',
path: '/demos/nested/menu2',
path: 'menu2',
meta: {
icon: 'ic:round-menu',
keepAlive: true,
title: $t('demos.nested.menu2'),
},
redirect: '/demos/nested/menu2/menu2-1',
children: [
{
name: 'Menu21Demo',
path: '/demos/nested/menu2/menu2-1',
path: 'menu2-1',
component: () => import('#/views/demos/nested/menu-2-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -177,12 +179,11 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu3Demo',
path: '/demos/nested/menu3',
path: 'menu3',
meta: {
icon: 'ic:round-menu',
title: $t('demos.nested.menu3'),
},
redirect: '/demos/nested/menu3/menu3-1',
children: [
{
name: 'Menu31Demo',
@ -201,11 +202,10 @@ const routes: RouteRecordRaw[] = [
icon: 'ic:round-menu',
title: $t('demos.nested.menu3_2'),
},
redirect: '/demos/nested/menu3/menu3-2/menu3-2-1',
children: [
{
name: 'Menu321Demo',
path: '/demos/nested/menu3/menu3-2/menu3-2-1',
path: 'menu3-2-1',
component: () =>
import('#/views/demos/nested/menu-3-2-1.vue'),
meta: {
@ -242,7 +242,6 @@ import type { RouteRecordRaw } from 'vue-router';
import { VBEN_LOGO_URL } from '@vben/constants';
import { BasicLayout } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
@ -253,11 +252,10 @@ const routes: RouteRecordRaw[] = [
},
name: 'Home',
path: '/home',
redirect: '/home/index',
children: [
{
name: 'HomeIndex',
path: '/home/index',
path: 'index',
component: () => import('#/views/home/index.vue'),
meta: {
icon: 'mdi:home',
@ -294,11 +292,11 @@ The route configuration items are mainly in the `meta` property of the route obj
```ts {5-8}
const routes = [
{
name: 'HomeIndex',
path: '/home/index',
name: 'Home',
path: '/home',
meta: {
icon: 'mdi:home',
title: $t('page.home.index'),
title: $t('page.home.title'),
},
},
];

18
docs/src/en/guide/essentials/server.md

@ -4,7 +4,7 @@
This document explains how to use Mock data and interact with the server in a development environment, involving technologies such as:
- [Nitro](https://nitro.unjs.io/) A lightweight backend server that can be deployed anywhere, used as a Mock server in the project.
- [Nitro](https://nitro.build/) A lightweight backend server that can be deployed anywhere, used as a Mock server in the project.
- [axios](https://axios-http.com/docs/intro) Used to send HTTP requests to interact with the server.
:::
@ -63,16 +63,18 @@ Based on the above configuration, we can use `/api` as the prefix for API reques
```ts
import axios from 'axios';
axios.get('/api/user').then((res) => {
console.log(res);
});
axios
.post('/api/auth/login', { username: 'vben', password: '123456' })
.then((res) => {
console.log(res);
});
```
At this point, the request will be proxied to `http://localhost:5320/api/user`.
At this point, the request will be proxied to `http://localhost:5320/api/auth/login`.
::: warning Note
From the browser's console Network tab, the request appears as `http://localhost:5555/api/user`. This is because the proxy configuration does not change the local request's URL.
From the browser's console Network tab, the request appears as `http://localhost:5555/api/auth/login`. This is because the proxy configuration does not change the local request's URL.
:::
@ -338,13 +340,13 @@ The new version no longer supports mock in the production environment. Please us
Mock data is an indispensable part of frontend development, serving as a key link in separating frontend and backend development. By agreeing on interfaces with the server side in advance and simulating request data and even logic, frontend development can proceed independently, without being blocked by the backend development process.
The project uses [Nitro](https://nitro.unjs.io/) for local mock data processing. The principle is to start an additional backend service locally, which is a real backend service that can handle requests and return data.
The project uses [Nitro](https://nitro.build/) for local mock data processing. The principle is to start an additional backend service locally, which is a real backend service that can handle requests and return data.
### Using Nitro
The mock service code is located in the `apps/backend-mock` directory. It does not need to be started manually and is already integrated into the project. You only need to run `pnpm dev` in the project root directory. After running successfully, the console will print `http://localhost:5320/api`, and you can access this address to view the mock service.
[Nitro](https://nitro.unjs.io/) syntax is simple, and you can configure and develop according to your needs. For specific configurations, you can refer to the [Nitro documentation](https://nitro.unjs.io/).
[Nitro](https://nitro.build/) syntax is simple, and you can configure and develop according to your needs. For specific configurations, you can refer to the [Nitro documentation](https://nitro.build/docs/).
## Disabling Mock Service

4
docs/src/en/guide/essentials/settings.md

@ -18,7 +18,7 @@ The rules are consistent with [Vite Env Variables and Modes](https://vitejs.dev/
- Only variables starting with `VITE_` will be embedded into the client-side package. You can access them in the project code like this:
```ts
console.log(import.meta.env.VITE_PROT);
console.log(import.meta.env.VITE_PORT);
```
- Variables starting with `VITE_GLOB_*` will be added to the `_app-config-{version}-{hash}.js` configuration file during packaging.
@ -365,7 +365,7 @@ const defaultPreferences: Preferences = {
contentPaddingTop: 0,
defaultAvatar:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
defaultHomePath: '/analytics',
defaultHomePath: '/dashboard/analytics',
dynamicTitle: true,
enableCheckUpdates: true,
enablePreferences: true,

2
docs/src/en/index.md

@ -69,7 +69,7 @@ features:
icon:
src: /logos/nitro.svg
details: Built-in Nitro Mock service makes your mock service more powerful.
link: https://nitro.unjs.io/
link: https://nitro.build/
linkText: Official Site
---

37
docs/src/guide/essentials/route.md

@ -55,6 +55,13 @@ const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
### 二级路由
::: tip
- 只有顶级路由的 `path` 需要以 `/` 开头;写在 `children` 中的子路由请使用相对路径片段,例如 `about`、`menu1`。
- 当父级路由未显式配置 `redirect` 时,框架会自动补全到第一个子路由的重定向;这依赖子路由使用相对路径。
:::
::: details 二级路由示例代码
```ts
@ -75,11 +82,10 @@ const routes: RouteRecordRaw[] = [
},
name: 'VbenProject',
path: '/vben-admin',
redirect: '/vben-admin/about',
children: [
{
name: 'VbenAbout',
path: '/vben-admin/about',
path: 'about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
badgeType: 'dot',
@ -101,6 +107,7 @@ export default routes;
::: tip
- 多级路由同样遵循“顶级绝对路径、子级相对路径”的组织方式,避免在 `children` 中重复写完整路径。
- 如果没有特殊情况,父级路由的 `redirect` 属性,不需要指定,默认会指向第一个子路由。
:::
@ -122,7 +129,6 @@ const routes: RouteRecordRaw[] = [
},
name: 'Demos',
path: '/demos',
redirect: '/demos/access',
children: [
// 嵌套菜单
{
@ -131,12 +137,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.nested.title'),
},
name: 'NestedDemos',
path: '/demos/nested',
redirect: '/demos/nested/menu1',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: '/demos/nested/menu1',
path: 'menu1',
component: () => import('#/views/demos/nested/menu-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -146,17 +151,16 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu2Demo',
path: '/demos/nested/menu2',
path: 'menu2',
meta: {
icon: 'ic:round-menu',
keepAlive: true,
title: $t('demos.nested.menu2'),
},
redirect: '/demos/nested/menu2/menu2-1',
children: [
{
name: 'Menu21Demo',
path: '/demos/nested/menu2/menu2-1',
path: 'menu2-1',
component: () => import('#/views/demos/nested/menu-2-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -168,12 +172,11 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu3Demo',
path: '/demos/nested/menu3',
path: 'menu3',
meta: {
icon: 'ic:round-menu',
title: $t('demos.nested.menu3'),
},
redirect: '/demos/nested/menu3/menu3-1',
children: [
{
name: 'Menu31Demo',
@ -192,11 +195,10 @@ const routes: RouteRecordRaw[] = [
icon: 'ic:round-menu',
title: $t('demos.nested.menu3_2'),
},
redirect: '/demos/nested/menu3/menu3-2/menu3-2-1',
children: [
{
name: 'Menu321Demo',
path: '/demos/nested/menu3/menu3-2/menu3-2-1',
path: 'menu3-2-1',
component: () =>
import('#/views/demos/nested/menu-3-2-1.vue'),
meta: {
@ -243,11 +245,10 @@ const routes: RouteRecordRaw[] = [
},
name: 'Home',
path: '/home',
redirect: '/home/index',
children: [
{
name: 'HomeIndex',
path: '/home/index',
path: 'index',
component: () => import('#/views/home/index.vue'),
meta: {
icon: 'mdi:home',
@ -284,11 +285,11 @@ export default routes;
```ts {5-8}
const routes = [
{
name: 'HomeIndex',
path: '/home/index',
name: 'Home',
path: '/home',
meta: {
icon: 'mdi:home',
title: $t('page.home.index'),
title: $t('page.home.title'),
},
},
];

18
docs/src/guide/essentials/server.md

@ -4,7 +4,7 @@
本文档介绍如何在开发环境下使用 Mock 数据和与服务端进行交互,涉及到的技术有:
- [Nitro](https://nitro.unjs.io/) 轻量级后端服务器,可部署在任何地方,项目用作于 Mock 服务器。
- [Nitro](https://nitro.build/) 轻量级后端服务器,可部署在任何地方,项目用作于 Mock 服务器。
- [axios](https://axios-http.com/docs/intro) 用于发送 HTTP 请求与服务端进行交互。
:::
@ -63,16 +63,18 @@ export default defineConfig(async () => {
```ts
import axios from 'axios';
axios.get('/api/user').then((res) => {
console.log(res);
});
axios
.post('/api/auth/login', { username: 'vben', password: '123456' })
.then((res) => {
console.log(res);
});
```
此时,请求会被代理到 `http://localhost:5320/api/user`。
此时,请求会被代理到 `http://localhost:5320/api/auth/login`。
::: warning 注意
从浏览器控制台的 Network 看,请求是 `http://localhost:5555/api/user`, 这是因为 proxy 配置不会改变本地请求的 url。
从浏览器控制台的 Network 看,请求是 `http://localhost:5555/api/auth/login`, 这是因为 proxy 配置不会改变本地请求的 url。
:::
@ -369,13 +371,13 @@ async function doRefreshToken() {
Mock 数据是前端开发过程中必不可少的一环,是分离前后端开发的关键链路。通过预先跟服务器端约定好的接口,模拟请求数据甚至逻辑,能够让前端开发独立自主,不会被服务端的开发进程所阻塞。
项目使用 [Nitro](https://nitro.unjs.io/) 来进行本地 mock 数据处理。其原理是本地额外启动一个后端服务,是一个真实的后端服务,可以处理请求,返回数据。
项目使用 [Nitro](https://nitro.build/) 来进行本地 mock 数据处理。其原理是本地额外启动一个后端服务,是一个真实的后端服务,可以处理请求,返回数据。
### Nitro 使用
Mock 服务代码位于`apps/backend-mock`目录下,无需手动启动,已经集成在项目中,只需要在项目根目录下运行`pnpm dev`即可,运行成功之后,控制台会打印 `http://localhost:5320/api`, 访问该地址即可查看 mock 服务。
[Nitro](https://nitro.unjs.io/) 语法简单,可以根据自己的需求进行配置及开发,具体配置可以查看 [Nitro 文档](https://nitro.unjs.io/)。
[Nitro](https://nitro.build/) 语法简单,可以根据自己的需求进行配置及开发,具体配置可以查看 [Nitro 文档](https://nitro.build/docs/)。
## 关闭 Mock 服务

4
docs/src/guide/essentials/settings.md

@ -18,7 +18,7 @@
- 只有以 `VITE_` 开头的变量会被嵌入到客户端侧的包中,你可以在项目代码中这样访问它们:
```ts
console.log(import.meta.env.VITE_PROT);
console.log(import.meta.env.VITE_PORT);
```
- 以 `VITE_GLOB_*` 开头的的变量,在打包的时候,会被加入 `_app-config-{version}-{hash}.js`配置文件当中.
@ -364,7 +364,7 @@ const defaultPreferences: Preferences = {
contentPaddingTop: 0,
defaultAvatar:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
defaultHomePath: '/analytics',
defaultHomePath: '/dashboard/analytics',
dynamicTitle: true,
enableCheckUpdates: true,
enablePreferences: true,

2
docs/src/index.md

@ -72,7 +72,7 @@ features:
icon:
src: /logos/nitro.svg
details: 内置 Nitro Mock 服务,让你的 mock 服务更加强大。
link: https://nitro.unjs.io/
link: https://nitro.build/
linkText: 官方站点
---

5
internal/lint-configs/eslint-config/src/configs/javascript.ts

@ -16,6 +16,7 @@ const rulesCoveredByOxlint = new Set([
'no-const-assign',
'no-constant-binary-expression',
'no-constant-condition',
'no-control-regex',
'no-debugger',
'no-delete-var',
'no-dupe-args',
@ -48,6 +49,8 @@ const rulesCoveredByOxlint = new Set([
'no-shadow-restricted-names',
'no-sparse-arrays',
'no-this-before-super',
'no-unassigned-vars',
'no-unexpected-multiline',
'no-unreachable',
'no-unsafe-finally',
'no-unsafe-negation',
@ -59,6 +62,7 @@ const rulesCoveredByOxlint = new Set([
'no-useless-catch',
'no-useless-escape',
'no-with',
'preserve-caught-error',
'require-yield',
'use-isnan',
'valid-typeof',
@ -102,7 +106,6 @@ export async function javascript(): Promise<Linter.Config[]> {
...recommendedRules,
'dot-notation': ['error', { allowKeywords: true }],
'keyword-spacing': 'off',
'no-control-regex': 'error',
'no-empty-function': 'off',
'no-octal': 'error',
'no-octal-escape': 'error',

10
internal/lint-configs/oxlint-config/src/configs/javascript.ts

@ -41,7 +41,7 @@ const javascript: OxlintConfig = {
'no-caller': 'error',
'no-case-declarations': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-control-regex': 'off',
'no-control-regex': 'error',
'no-debugger': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'no-fallthrough': 'error',
@ -68,6 +68,8 @@ const javascript: OxlintConfig = {
],
'no-template-curly-in-string': 'error',
'no-throw-literal': 'error',
'no-unassigned-vars': 'error',
'no-unexpected-multiline': 'error',
'no-unused-expressions': [
'error',
{
@ -108,6 +110,12 @@ const javascript: OxlintConfig = {
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'error',
'preserve-caught-error': [
'error',
{
requireCatchParameter: false,
},
],
'symbol-description': 'error',
'unicode-bom': ['error', 'never'],
'use-isnan': [

1
internal/lint-configs/oxlint-config/src/configs/vue.ts

@ -2,6 +2,7 @@ import type { OxlintConfig } from 'oxlint';
const vue: OxlintConfig = {
rules: {
'vue/no-reserved-component-names': 'off',
'vue/prefer-import-from-vue': 'error',
},
};

8
internal/node-utils/src/git.ts

@ -20,7 +20,13 @@ async function getStagedFiles(): Promise<string[]> {
'-z',
]);
let changedList = stdout ? stdout.replace(/\0$/, '').split('\0') : [];
const nullSeparator = '\u0000';
const normalizedStdout = stdout.endsWith(nullSeparator)
? stdout.slice(0, -1)
: stdout;
let changedList = normalizedStdout
? normalizedStdout.split(nullSeparator)
: [];
changedList = changedList.map((item) => path.resolve(process.cwd(), item));
const changedSet = new Set(changedList);
changedSet.delete('');

12
packages/@core/base/shared/src/utils/tree.ts

@ -79,16 +79,22 @@ function filterTree<T extends Record<string, any>>(
*/
function mapTree<T, V extends Record<string, any>>(
tree: T[],
mapper: (node: T) => V,
mapper: (node: T, parent: null | V) => V,
options?: TreeConfigOptions,
parent: null | V = null,
): V[] {
const { childProps } = options || {
childProps: 'children',
};
return tree.map((node) => {
const mapperNode: Record<string, any> = mapper(node);
const mapperNode: Record<string, any> = mapper(node, parent as null | V);
if (mapperNode[childProps]) {
mapperNode[childProps] = mapTree(mapperNode[childProps], mapper, options);
mapperNode[childProps] = mapTree(
mapperNode[childProps],
mapper,
options,
mapperNode as V,
);
}
return mapperNode as V;
});

1
packages/@core/base/typings/src/basic.d.ts

@ -8,6 +8,7 @@ type SelectOption = BasicOption;
type TabOption = BasicOption;
interface BasicUserInfo {
[key: string]: any;
/**
* 头像
*/

4
packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap

@ -17,7 +17,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"contentPaddingRight": 0,
"contentPaddingTop": 0,
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
"defaultHomePath": "/analytics",
"defaultHomePath": "/dashboard",
"dynamicTitle": true,
"enableCheckUpdates": true,
"enableCopyPreferences": true,
@ -66,6 +66,8 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"logo": {
"enable": true,
"fit": "contain",
"logoMode": "icon",
"showText": true,
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
},
"navigation": {

2
packages/@core/preferences/src/config.ts

@ -17,7 +17,7 @@ const defaultPreferences: Preferences = {
contentPaddingTop: 0,
defaultAvatar:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
defaultHomePath: '/analytics',
defaultHomePath: '/dashboard',
dynamicTitle: true,
enableCheckUpdates: true,
enableCopyPreferences: true,

70
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts

@ -101,6 +101,47 @@ describe('formApi', () => {
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat: (
value: any,
setValue: any,
_values: any,
ctx: any,
) => {
setValue('normalizedName', value?.trim());
setValue('$root.firstRow', ctx?.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
} as any,
],
});
const formActions: any = {
meta: {},
values: {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
},
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
firstRow: 1,
});
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@ -229,6 +270,35 @@ describe('updateSchema', () => {
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should update child schema by parent path', () => {
instance.state = {
schema: [
{
children: [
{ component: 'text', fieldName: 'name', label: 'Name' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
fieldName: 'contacts',
type: 'array',
} as any,
],
};
instance.updateSchema([
{
fieldName: 'contacts.name',
label: 'Full Name',
},
]);
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
'Full Name',
);
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
'Phone',
);
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },

182
packages/@core/ui-kit/form-ui/src/components/form-field-array.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { FormSchema } from '../types';
import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue';
@ -9,11 +9,12 @@ import {
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { cn, set } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
@ -23,6 +24,8 @@ const props = withDefaults(
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/**
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
*/
@ -30,6 +33,8 @@ const props = withDefaults(
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
@ -51,6 +56,8 @@ const props = withDefaults(
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
@ -67,12 +74,33 @@ const { fields, push, remove } = useFieldArray<Record<string, any>>(
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
const gridStyle = computed(() => {
const columns = [
...(props.showIndex ? ['3rem'] : []),
...props.schema.map(() => 'minmax(0, 1fr)'),
'4rem',
];
return {
gridTemplateColumns: columns.join(' '),
};
});
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
return Object.fromEntries(props.schema.map((col) => [col.fieldName, null]));
const row: Record<string, any> = {};
props.schema.forEach((col) => {
const value =
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
? col.defaultValue
: 'type' in col && col.type === 'array'
? []
: null;
set(row, col.fieldName, value);
});
return row;
}
function addRow() {
@ -89,83 +117,101 @@ function removeRow(index: number) {
remove(index);
}
/**
* 把列定义转换为子单元格 FormField 所需的 props。
* - fieldName 替换为嵌套路径 `name[index].fieldName`,让校验与取值落在数组元素上
* - hideLabel:表头已展示列名,单元格不重复显示
*/
function cellProps(col: FormSchema, index: number) {
return {
...col,
commonComponentProps: {},
disabled: props.disabled,
fieldName: `${arrayPath.value}[${index}].${col.fieldName}`,
formFieldProps: {},
hideLabel: true,
};
function rowSchemas(index: number) {
return props.schema.map((col) =>
createArrayChildSchema(col as never, {
arrayField: arrayPath.value,
commonConfig: props.commonConfig,
disabled: props.disabled,
globalCommonConfig: props.globalCommonConfig,
index,
}),
);
}
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<table class="w-full border-collapse">
<thead>
<tr class="border-border border-b">
<th
v-if="showIndex"
class="text-muted-foreground w-12 px-2 py-2 text-left text-sm font-normal"
>
#
</th>
<th
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</th>
<th
class="text-muted-foreground w-16 px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b align-top"
<div class="border-border/70 overflow-hidden rounded-md border">
<div
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<td v-if="showIndex" class="text-muted-foreground px-2 py-3 text-sm">
{{ index + 1 }}
</td>
<td v-for="col in schema" :key="col.fieldName" class="px-2 py-2">
<FormField v-bind="cellProps(col, index)" />
</td>
<td class="px-2 py-3">
<VbenIconButton
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
#
</div>
<div
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</div>
<div
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</div>
</div>
<div
v-for="(entry, index) in fields"
:key="entry.key"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
>
<span class="sm:hidden">#</span>
{{ index + 1 }}
</div>
<template
v-for="(childSchema, childIndex) in rowSchemas(index)"
:key="childSchema.fieldName"
>
<div class="min-w-0 py-2 sm:px-2">
<div
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
>
<X class="size-4" />
</VbenIconButton>
</td>
</tr>
</tbody>
</table>
<div
v-if="fields.length === 0"
class="text-muted-foreground border-border/60 border-b py-6 text-center text-sm"
>
{{ emptyText }}
<VbenRenderContent :content="schema?.[childIndex]?.label" />
</div>
<FormField
v-bind="childSchema"
:class="childSchema.formItemClass"
/>
</div>
</template>
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
<VbenIconButton
type="button"
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</div>
</div>
<div
v-if="fields.length === 0"
class="text-muted-foreground py-6 text-center text-sm"
>
{{ emptyText }}
</div>
</div>
<VbenButton
variant="outline"
size="sm"
type="button"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"

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

@ -30,6 +30,10 @@ import {
} from '@vben-core/shared/utils';
import { resolveFieldNamePath } from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
function getDefaultState(): VbenFormProps {
return {
@ -393,25 +397,10 @@ export class FormApi {
);
return;
}
const currentSchema = [...(this.state?.schema ?? [])];
const updatedMap: Record<string, any> = {};
updated.forEach((item) => {
if (item.fieldName) {
updatedMap[item.fieldName] = item;
}
});
currentSchema.forEach((schema, index) => {
const updatedData = updatedMap[schema.fieldName];
if (updatedData) {
currentSchema[index] = mergeWithArrayOverride(
updatedData,
schema,
) as FormSchema;
}
});
const currentSchema = this.updateSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
@ -456,6 +445,79 @@ export class FormApi {
return validateResult;
}
private applyValueFormatBySchemas(
schemas: FormSchema[],
values: Record<string, any>,
parentPath?: string,
parentContext?: {
arrayField?: string;
row?: Record<string, any>;
rowIndex?: number;
rowPath?: string;
},
) {
schemas.forEach((schema) => {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? this.resolveValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values,
row,
};
const children = getFormArraySchemaChildren(schema);
if (children.length > 0) {
const arrayValue = this.resolveValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
this.applyValueFormatBySchemas(
children as FormSchema[],
values,
rowPath,
{
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
},
);
});
}
}
if (schema.valueFormat) {
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(
values,
this.resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values,
schemaContext,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
}
});
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
@ -611,30 +673,7 @@ export class FormApi {
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
const currentSchema = this.state?.schema ?? [];
currentSchema.forEach((schema) => {
if (!schema.valueFormat) {
return;
}
const fieldName = schema.fieldName;
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(values, key, nextValue);
},
values,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
});
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
return values;
};
@ -654,6 +693,27 @@ export class FormApi {
});
};
private resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
@ -666,6 +726,51 @@ export class FormApi {
return get(values, fieldName);
}
private resolveValueFormatFieldName(fieldName: string, parentPath?: string) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as FormSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as FormSchema;
}
return schema;
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
@ -680,6 +785,48 @@ export class FormApi {
set(values, fieldName, value);
}
private updateSchemaList(
currentSchema: FormSchema[],
updated: Partial<FormSchema>[],
): FormSchema[] {
return currentSchema.map((schema): FormSchema => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
}
const children = getFormArraySchemaChildren(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated
.map((item) => {
const fieldName = item.fieldName
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName
? ({
...item,
fieldName,
} as Partial<FormSchema>)
: undefined;
})
.filter(Boolean) as Partial<FormSchema>[];
if (childUpdates.length === 0) {
return schema;
}
return this.setSchemaChildren(
schema,
this.updateSchemaList(children as FormSchema[], childUpdates),
);
});
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];

7
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

@ -78,6 +78,9 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
const collapseOpen = ref(!defaultCollapsed);
function getFormApi(): FormActions {
@ -338,7 +341,7 @@ onUnmounted(() => {
<FormItem
v-show="isShow"
:class="{
'form-valid-error': isInValid,
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
@ -406,7 +409,7 @@ onUnmounted(() => {
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
isInValid,
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"

101
packages/@core/ui-kit/form-ui/src/form-render/form.vue

@ -2,27 +2,19 @@
import type { GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type {
FormCommonConfig,
FormRenderProps,
FormSchema,
FormShape,
} from '../types';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed } from 'vue';
import { Form } from '@vben-core/shadcn-ui';
import {
cn,
isFunction,
isString,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
import { useExpandable } from './expandable';
import FormField from './form-field.vue';
import { getBaseRules, getDefaultValueInZodStack } from './helper';
import { createFormFieldSchema } from './schema';
interface Props extends FormRenderProps {}
@ -95,78 +87,23 @@ const formCollapsed = computed(() => {
return props.collapsed && isCalculated.value;
});
const computedSchema = computed(
(): (Omit<FormSchema, 'formFieldProps'> & {
commonComponentProps: Record<string, any>;
formFieldProps: Record<string, any>;
})[] => {
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
// 处理函数形式的formItemClass
let resolvedSchemaFormItemClass = schema.formItemClass;
if (isFunction(schema.formItemClass)) {
try {
resolvedSchemaFormItemClass = schema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
const computedSchema = computed((): NormalizedFormFieldSchema[] => {
return (props.schema || []).map((schema, index) => {
const keepIndex = keepFormItemIndex.value;
return {
colon,
disabled,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideLabel,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...schema,
commonComponentProps: componentProps,
componentProps: schema.componentProps,
controlClass: cn(controlClass, schema.controlClass),
formFieldProps: {
...formFieldProps,
...schema.formFieldProps,
},
formItemClass: cn(
'shrink-0',
{ hidden },
formItemClass,
resolvedSchemaFormItemClass,
),
labelClass: cn(labelClass, schema.labelClass),
};
const hidden =
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
props.showCollapseButton && !!formCollapsed.value && keepIndex
? keepIndex <= index
: false;
return createFormFieldSchema(schema as never, {
commonConfig: props.commonConfig,
globalCommonConfig: props.globalCommonConfig,
hidden,
});
},
);
});
});
</script>
<template>

341
packages/@core/ui-kit/form-ui/src/form-render/schema.ts

@ -0,0 +1,341 @@
import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormFieldProps,
FormItemDependencies,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
} from '../types';
import {
get,
isFunction,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
commonComponentProps: MaybeComponentProps;
formFieldProps: Record<string, any>;
formItemClass: string;
};
interface CreateFormFieldSchemaOptions {
commonConfig?: FormCommonConfig;
disabled?: boolean;
forceHideLabel?: boolean;
globalCommonConfig?: FormCommonConfig;
hidden?: boolean;
}
interface CreateArrayChildSchemaOptions extends CreateFormFieldSchemaOptions {
arrayField: string;
index: number;
}
function createSchemaContext(
baseContext: FormSchemaContext,
values?: Partial<Record<string, any>>,
): FormSchemaContext {
const rootValues = values as Record<string, any> | undefined;
return {
...baseContext,
rootValues,
row:
baseContext.rowPath && rootValues
? get(rootValues, baseContext.rowPath)
: undefined,
};
}
function scopeRowFieldName(rowPath: string, fieldName: string) {
if (!fieldName) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${rowPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === rowPath || fieldName.startsWith(`${rowPath}.`)) {
return fieldName;
}
return `${rowPath}.${fieldName}`;
}
function wrapComponentProps(
componentProps: AnyFormSchema['componentProps'],
baseContext: FormSchemaContext,
) {
if (!isFunction(componentProps)) {
return componentProps;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
componentProps(values, actions, createSchemaContext(baseContext, values));
}
function wrapCustomParamsRender(
render: AnyFormSchema['help'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapRenderComponentContent(
render: AnyFormSchema['renderComponentContent'],
baseContext: FormSchemaContext,
) {
if (!isFunction(render)) {
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
if (!isFunction(handler)) {
return handler;
}
return ((
values: Partial<Record<string, any>>,
actions: FormActions,
controller: any,
) =>
handler(
values,
actions,
controller,
createSchemaContext(baseContext, values),
)) as T;
}
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
) {
if (!dependencies) {
return dependencies;
}
const rowPath = baseContext.rowPath;
if (!rowPath) {
return dependencies;
}
return {
...dependencies,
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
if: wrapDependencyFn(dependencies.if, baseContext),
required: wrapDependencyFn(dependencies.required, baseContext),
rules: wrapDependencyFn(dependencies.rules, baseContext),
show: wrapDependencyFn(dependencies.show, baseContext),
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
triggerFields:
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [],
};
}
function createArrayComponentProps(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const componentProps = schema.componentProps;
const arrayProps = 'arrayProps' in schema ? schema.arrayProps : undefined;
const children = getFormArraySchemaChildren(schema);
const commonConfig = options.commonConfig;
const globalCommonConfig = options.globalCommonConfig;
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
...arrayProps,
...componentProps(values, actions),
commonConfig,
globalCommonConfig,
...schemaProps,
});
}
return {
...arrayProps,
...componentProps,
commonConfig,
globalCommonConfig,
...schemaProps,
};
}
function createArrayFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions,
) {
const restSchema = { ...(schema as AnyFormSchema & Record<string, any>) };
Reflect.deleteProperty(restSchema, 'arrayProps');
Reflect.deleteProperty(restSchema, 'children');
Reflect.deleteProperty(restSchema, 'type');
return {
...restSchema,
component: 'VbenFormFieldArray',
componentProps: createArrayComponentProps(schema, options),
};
}
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children;
}
const componentProps = schema.componentProps;
if (
!isFunction(componentProps) &&
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema;
}
return [];
}
export function isFormArraySchema(schema: Partial<AnyFormSchema>) {
return (
('type' in schema && schema.type === 'array') ||
schema.component === 'VbenFormFieldArray' ||
getFormArraySchemaChildren(schema).length > 0
);
}
export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
return scopeRowFieldName(rowPath, fieldName);
}
export function createFormFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions = {},
): NormalizedFormFieldSchema {
const commonConfig = mergeWithArrayOverride(
options.commonConfig ?? {},
options.globalCommonConfig ?? {},
);
const {
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
hideLabel = false,
hideRequiredMark = false,
labelClass = '',
labelWidth = 100,
modelPropName = '',
wrapperClass = '',
} = commonConfig;
const normalizedSchema = isFormArraySchema(schema)
? createArrayFieldSchema(schema, options)
: schema;
let resolvedSchemaFormItemClass = normalizedSchema.formItemClass;
if (isFunction(normalizedSchema.formItemClass)) {
try {
resolvedSchemaFormItemClass = normalizedSchema.formItemClass();
} catch (error) {
console.error('Error calling formItemClass function:', error);
resolvedSchemaFormItemClass = '';
}
}
return {
colon,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideRequiredMark,
labelWidth,
modelPropName,
wrapperClass,
...normalizedSchema,
commonComponentProps: componentProps as MaybeComponentProps,
componentProps: normalizedSchema.componentProps,
controlClass: [controlClass, normalizedSchema.controlClass]
.filter(Boolean)
.join(' '),
formFieldProps: {
...formFieldProps,
...normalizedSchema.formFieldProps,
},
formItemClass: [
'shrink-0',
options.hidden ? 'hidden' : '',
formItemClass,
resolvedSchemaFormItemClass,
]
.filter(Boolean)
.join(' '),
labelClass: [labelClass, normalizedSchema.labelClass]
.filter(Boolean)
.join(' '),
disabled: options.disabled ?? normalizedSchema.disabled ?? disabled,
hideLabel:
options.forceHideLabel ?? normalizedSchema.hideLabel ?? hideLabel,
} as NormalizedFormFieldSchema;
}
export function createArrayChildSchema(
schema: AnyFormSchema,
options: CreateArrayChildSchemaOptions,
): NormalizedFormFieldSchema {
const rowPath = `${options.arrayField}[${options.index}]`;
const fieldName = resolveArrayChildFieldName(rowPath, schema.fieldName);
const baseContext: FormSchemaContext = {
arrayField: options.arrayField,
fieldName,
originalFieldName: schema.fieldName,
rowIndex: options.index,
rowPath,
};
return createFormFieldSchema(
{
...schema,
componentProps: wrapComponentProps(schema.componentProps, baseContext),
dependencies: scopeDependencies(schema.dependencies, baseContext),
fieldName,
help: wrapCustomParamsRender(schema.help, baseContext),
renderComponentContent: wrapRenderComponentContent(
schema.renderComponentContent,
baseContext,
),
},
{
commonConfig: options.commonConfig,
disabled: options.disabled || schema.disabled,
forceHideLabel: true,
globalCommonConfig: options.globalCommonConfig,
},
);
}

1
packages/@core/ui-kit/form-ui/src/index.ts

@ -4,6 +4,7 @@ export type {
BaseFormComponentType,
ExtendedFormApi,
FormLayout,
FormSchemaContext,
VbenFormFieldArrayProps,
VbenFormProps,
FormSchema as VbenFormSchema,

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

@ -66,6 +66,23 @@ export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export type FormActions = FormContext<GenericObject>;
export interface FormSchemaContext {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
fieldName?: string;
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: Record<string, any>;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
rowIndex?: number;
/** 当前行路径,例如 contacts[0] */
rowPath?: string;
}
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
@ -73,6 +90,7 @@ export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => Component | string)
| string;
@ -87,18 +105,21 @@ type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => T;
type FormItemDependenciesConditionWithRules = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
@ -145,6 +166,7 @@ type ComponentProps =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => MaybeComponentProps)
| MaybeComponentProps;
@ -233,12 +255,14 @@ export interface FormCommonConfig {
type RenderComponentContentType = (
value: Partial<Record<string, any>>,
api: FormActions,
ctx?: FormSchemaContext,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => P & Record<string, any>)
| (P & Record<string, any>);
@ -253,6 +277,7 @@ export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
ctx?: FormSchemaContext,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
@ -303,10 +328,32 @@ type FormSchemaFallback<T extends BaseFormComponentType> = {
componentProps?: ComponentProps;
} & FormSchemaBody;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
> =
| FormArraySchema<T, P>
| FormSchemaDiscriminated<T, P>
| FormSchemaFallback<T>;
/**
* 数组编辑器(VbenFormFieldArray)的组件参数
@ -319,17 +366,23 @@ export interface VbenFormFieldArrayProps<
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema) */
schema: FormSchema<T, P>[];
schema?: FormSchema<T, P>[];
/** 是否显示序号列 */
showIndex?: boolean;
}

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

@ -13,7 +13,7 @@ interface Props {
*/
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/**
* @zh_CN logo高度, 只在 logoMode=full时失效
* @zh_CN logo高度, 只在 logoMode=full时生效
*/
fullLogoHeight?: number | string;
/**

12
packages/@core/ui-kit/shadcn-ui/src/components/segmented/segmented.vue

@ -6,6 +6,7 @@ import { computed } from 'vue';
import { TabsTrigger } from 'reka-ui';
import { Tabs, TabsContent, TabsList } from '../../ui';
import { VbenTooltip } from '../tooltip';
import TabsIndicator from './tabs-indicator.vue';
interface Props {
@ -52,9 +53,16 @@ function activeClass(tab: string): string[] {
<TabsTrigger
:value="tab.value"
:class="activeClass(tab.value)"
class="hover:text-primary z-20 inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
class="hover:text-primary z-20 size-full inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
>
{{ tab.label }}
<VbenTooltip :delay-duration="300" side="bottom">
<template #trigger>
<div class="whitespace-nowrap overflow-hidden text-ellipsis px-1">
{{ tab.label }}
</div>
</template>
{{ tab.label }}
</VbenTooltip>
</TabsTrigger>
</template>
</TabsList>

4
packages/@core/ui-kit/shadcn-ui/src/components/segmented/tabs-indicator.vue

@ -23,13 +23,13 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'absolute bottom-0 left-0 z-10 h-full w-1/2 translate-x-(--reka-tabs-indicator-position) rounded-full px-0 py-1 pr-0.5 transition-[width,transform] duration-300',
'absolute bottom-0 left-0 z-10 size-full translate-x-(--reka-tabs-indicator-position) rounded-full py-1 transition-[width,transform] duration-300',
props.class,
)
"
>
<div
class="bg-background text-foreground inline-flex h-full w-full items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
class="bg-background text-foreground inline-flex size-full items-center justify-center rounded-md text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
>
<slot></slot>
</div>

2
packages/@core/ui-kit/shadcn-ui/src/ui/tabs/TabsList.vue

@ -21,7 +21,7 @@ const delegatedProps = reactiveOmit(props, 'class');
v-bind="delegatedProps"
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-md p-1',
props.class,
)
"

10
packages/@core/ui-kit/shadcn-ui/src/ui/tooltip/TooltipContent.vue

@ -5,7 +5,12 @@ import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
useForwardPropsEmits,
} from 'reka-ui';
defineOptions({
inheritAttrs: false,
@ -38,12 +43,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
data-slot="tooltip-content"
:class="
cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-popup w-fit origin-(--reka-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-popup w-fit origin-(--reka-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance border border-border',
props.class,
)
"
>
<slot></slot>
<TooltipArrow class="fill-accent stroke-gray-200" />
</TooltipContent>
</TooltipPortal>
</template>

240
packages/effects/access/src/__tests__/accessible.test.ts

@ -0,0 +1,240 @@
import type { RouteRecordRaw } from '@vben/types';
import { describe, expect, it } from 'vitest';
import { generateAccessible } from '../accessible';
// generateAccessible 会操作传入的 router 实例。这里用最小 stub 覆盖它实际调用的方法:
// - getRoutes(): 返回 [] -> 不存在根路由 '/', 走 router.addRoute 分支
// - addRoute/removeRoute: 空实现
// 我们只断言返回的 accessibleRoutes 上自动生成的 redirect。
function createRouterStub() {
return {
addRoute: () => {},
getRoutes: () => [],
removeRoute: () => {},
} as any;
}
async function generate(routes: RouteRecordRaw[]) {
const { accessibleRoutes } = await generateAccessible('frontend', {
router: createRouterStub(),
routes,
});
return accessibleRoutes;
}
function findByName(
routes: RouteRecordRaw[],
name: string,
): RouteRecordRaw | undefined {
for (const route of routes) {
if (route.name === name) {
return route;
}
if (route.children) {
const found = findByName(route.children as RouteRecordRaw[], name);
if (found) {
return found;
}
}
}
return undefined;
}
describe('generateAccessible - redirect normalization', () => {
it('不为动态参数(:id)首子路由的父级生成 redirect', async () => {
const routes = [
{
name: 'DyeSets',
path: 'dye-sets',
children: [
{
name: 'DyeSetDetail',
path: ':id',
meta: { hideInMenu: true, title: 'detail' },
},
],
meta: { title: 'dye-sets' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'DyeSets')?.redirect).toBeUndefined();
});
it('父级为对象 redirect({name}) 且含 :id 子路由时不抛异常且不生成 redirect', async () => {
const routes = [
{
name: 'Production',
path: '/production',
redirect: { name: 'ProductionTasks' },
children: [
{
name: 'ProductionTasks',
path: 'production-tasks',
children: [
{
name: 'ProductionTaskDetail',
path: ':id',
meta: { hideInMenu: true, title: 'detail' },
},
{
name: 'ProductionTaskMatch',
path: ':id/match',
meta: { hideInMenu: true, title: 'match' },
},
],
meta: { title: 'tasks' },
},
],
meta: { title: 'production' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
// 顶级对象 redirect 保持不变
expect(findByName(result, 'Production')?.redirect).toEqual({
name: 'ProductionTasks',
});
// :id 首子路由的父级不生成 redirect
expect(findByName(result, 'ProductionTasks')?.redirect).toBeUndefined();
});
it('父级为对象 redirect 时,普通相对首子路由回退用 parent.path 拼接', async () => {
const routes = [
{
name: 'Setting',
path: '/setting',
redirect: { name: 'SettingService' },
children: [
{
name: 'SettingGroup',
path: 'group',
children: [
{
name: 'SettingService',
path: 'service',
meta: { title: 'service' },
},
],
meta: { title: 'group' },
},
],
meta: { title: 'setting' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'SettingGroup')?.redirect).toBe(
'/setting/group/service',
);
});
it('深层嵌套(上游风格)相对路径逐级生成正确的累计绝对 redirect', async () => {
const routes = [
{
name: 'Demos',
path: '/demos',
children: [
{
name: 'NestedDemos',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: 'menu1',
meta: { title: 'menu1' },
},
{
name: 'Menu2Demo',
path: 'menu2',
children: [
{
name: 'Menu21Demo',
path: 'menu2-1',
meta: { title: 'menu2-1' },
},
],
meta: { title: 'menu2' },
},
],
meta: { title: 'nested' },
},
],
meta: { title: 'demos' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
// Demos 重定向到第一级子路由,子路由继续级联到叶子
expect(findByName(result, 'Demos')?.redirect).toBe('/demos/nested');
expect(findByName(result, 'NestedDemos')?.redirect).toBe(
'/demos/nested/menu1',
);
expect(findByName(result, 'Menu2Demo')?.redirect).toBe(
'/demos/nested/menu2/menu2-1',
);
});
it('首子路由为绝对路径(/foo)时不生成 redirect', async () => {
const routes = [
{
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
meta: { title: 'analytics' },
},
],
meta: { title: 'dashboard' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'Dashboard')?.redirect).toBeUndefined();
});
it('首子路由为空 path 时不生成 redirect', async () => {
const routes = [
{
name: 'HideChildrenParent',
path: 'hide-menu-children',
children: [
{
name: 'HideChildren',
path: '',
meta: { title: 'hide' },
},
],
meta: { title: 'parent' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'HideChildrenParent')?.redirect).toBeUndefined();
});
it('已存在的 redirect 保持不变', async () => {
const routes = [
{
name: 'Custom',
path: '/custom',
redirect: '/custom/keep',
children: [
{
name: 'CustomChild',
path: 'child',
meta: { title: 'child' },
},
],
meta: { title: 'custom' },
},
] as unknown as RouteRecordRaw[];
const result = await generate(routes);
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep');
});
});

30
packages/effects/access/src/accessible.ts

@ -115,7 +115,7 @@ async function generateRoutes(
* 1. 对未添加redirect的路由添加redirect
* 2. 将懒加载的组件名称修改为当前路由的名称(如果启用了keep-alive的话)
*/
resultRoutes = mapTree(resultRoutes, (route) => {
resultRoutes = mapTree(resultRoutes, (route, parent) => {
// 重新包装component,使用与路由名称相同的name以支持keep-alive的条件缓存。
if (
route.meta?.keepAlive &&
@ -144,12 +144,34 @@ async function generateRoutes(
}
const firstChild = route.children[0];
// 如果子路由不是以/开头,则直接返回,这种情况需要计算全部父级的path才能得出正确的path,这里不做处理
if (!firstChild?.path || !firstChild.path.startsWith('/')) {
if (!firstChild?.path || firstChild.path.startsWith('/')) {
return route;
}
route.redirect = firstChild.path;
// fork 定制:如果第一个子路由是动态路由(如 :id),说明当前路由本身是一个
// “列表+详情”页面(渲染自身组件),不应自动重定向到未填充的动态参数,
// 否则地址栏会出现字面量 ":id" 或匹配失败导致 404。
// 详见对上游重构 commit f00a8812 的修复。
if (firstChild.path.startsWith(':')) {
return route;
}
// 拼接子路由的重定向绝对路径。
// - 当 parent.redirect 为字符串时,它已经是累计好的绝对路径,直接替换最后一段
// 即可正确支持任意层级的深层嵌套(如 /demos/nested/menu2/menu2-1)。
// - fork 定制:后端菜单可能传入对象形式的 redirect(如 { name }),无法 split,
// 此时回退到使用 parent.path 拼接(这类 parent 为顶级路由,path 为绝对路径)。
if (parent && parent.redirect && isString(parent.redirect)) {
const parentSplit = parent.redirect.split('/');
parentSplit.splice(-1, 2, route.path, firstChild.path);
const redirectPath = parentSplit.join('/');
route.redirect = redirectPath;
} else if (parent && parent.redirect) {
route.redirect = `${parent.path}/${route.path}/${firstChild.path}`;
} else {
route.redirect = `${route.path}/${firstChild.path}`;
}
return route;
});

1
packages/effects/layouts/src/basic/content/content.vue

@ -44,7 +44,6 @@ const showComponent = (route: RouteLocationNormalizedLoadedGeneric) => {
v-if="getEnabledTransition"
:name="getTransitionName(route)"
appear
mode="out-in"
>
<KeepAlive
v-if="keepAlive"

39
packages/effects/layouts/src/basic/header/header.vue

@ -60,25 +60,26 @@ const rightSlots = computed(() => {
index: REFERENCE_VALUE + 10,
name: 'preferences',
});
// 将偏好设置中的子功能分组到同一个按钮位置控制逻辑下
if (preferences.widget.themeToggle) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
}
// 主题、语言、时区等子功能由各自的 widget 开关独立控制,
// 不应跟随偏好设置按钮的显示与否(如 enablePreferences 为 false 或按钮位于其他位置时)
if (preferences.widget.themeToggle) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
// 全屏
if (preferences.widget.fullscreen) {

2
packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue

@ -36,7 +36,7 @@ function handleClick() {
<VbenTooltip v-if="slots.tip || tip" side="bottom">
<template #trigger>
<CircleHelp class="ml-1 size-3 cursor-help" />
<CircleHelp class="ml-1 size-3 cursor-help pointer-events-auto" />
</template>
<slot name="tip">
<template v-if="tip">

26
packages/stores/src/modules/user.ts

@ -1,28 +1,6 @@
import { acceptHMRUpdate, defineStore } from 'pinia';
import type { BasicUserInfo } from '@vben-core/typings';
interface BasicUserInfo {
[key: string]: any;
/**
* 头像
*/
avatar: string;
/**
* 用户昵称
*/
realName: string;
/**
* 用户角色
*/
roles?: string[];
/**
* 用户id
*/
userId: string;
/**
* 用户名
*/
username: string;
}
import { acceptHMRUpdate, defineStore } from 'pinia';
interface AccessState {
/**

1
playground/src/adapter/component/index.ts

@ -379,6 +379,7 @@ function cropImage(file: File, aspectRatio: string | undefined) {
]),
centered: true,
width: 548,
zIndex: 9999,
keyboard: false,
maskClosable: false,
closable: false,

4
playground/src/router/routes/modules/dashboard.ts

@ -14,7 +14,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Analytics',
path: '/analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
@ -25,7 +25,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Workspace',
path: '/workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',

110
playground/src/router/routes/modules/demos.ts

@ -21,11 +21,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.access.frontendPermissions'),
},
name: 'AccessDemos',
path: '/demos/access',
path: 'access',
children: [
{
name: 'AccessPageControlDemo',
path: '/demos/access/page-control',
path: 'page-control',
component: () => import('#/views/demos/access/index.vue'),
meta: {
icon: 'mdi:page-previous-outline',
@ -35,7 +35,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessButtonControlDemo',
path: '/demos/access/button-control',
path: 'button-control',
component: () => import('#/views/demos/access/button-control.vue'),
meta: {
icon: 'mdi:button-cursor',
@ -44,7 +44,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessMenuVisible403Demo',
path: '/demos/access/menu-visible-403',
path: 'menu-visible-403',
component: () =>
import('#/views/demos/access/menu-visible-403.vue'),
meta: {
@ -56,7 +56,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessSuperVisibleDemo',
path: '/demos/access/super-visible',
path: 'super-visible',
component: () => import('#/views/demos/access/super-visible.vue'),
meta: {
authority: ['super'],
@ -66,7 +66,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessAdminVisibleDemo',
path: '/demos/access/admin-visible',
path: 'admin-visible',
component: () => import('#/views/demos/access/admin-visible.vue'),
meta: {
authority: ['admin'],
@ -76,7 +76,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'AccessUserVisibleDemo',
path: '/demos/access/user-visible',
path: 'user-visible',
component: () => import('#/views/demos/access/user-visible.vue'),
meta: {
authority: ['user'],
@ -93,11 +93,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.features.title'),
},
name: 'FeaturesDemos',
path: '/demos/features',
path: 'features',
children: [
{
name: 'LoginExpiredDemo',
path: '/demos/features/login-expired',
path: 'login-expired',
component: () =>
import('#/views/demos/features/login-expired/index.vue'),
meta: {
@ -107,7 +107,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'IconsDemo',
path: '/demos/features/icons',
path: 'icons',
component: () => import('#/views/demos/features/icons/index.vue'),
meta: {
icon: 'lucide:annoyed',
@ -116,7 +116,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'WatermarkDemo',
path: '/demos/features/watermark',
path: 'watermark',
component: () =>
import('#/views/demos/features/watermark/index.vue'),
meta: {
@ -126,7 +126,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'PreferencesExtensionDemo',
path: '/demos/features/preferences-extension',
path: 'preferences-extension',
component: () =>
import('#/views/demos/features/preferences-extension/index.vue'),
meta: {
@ -136,7 +136,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FeatureTabsDemo',
path: '/demos/features/tabs',
path: 'tabs',
component: () => import('#/views/demos/features/tabs/index.vue'),
meta: {
icon: 'lucide:app-window',
@ -145,7 +145,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FeatureTabDetailDemo',
path: '/demos/features/tabs/detail/:id',
path: 'detail/:id',
component: () =>
import('#/views/demos/features/tabs/tab-detail.vue'),
meta: {
@ -157,7 +157,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'HideChildrenInMenuParentDemo',
path: '/demos/features/hide-menu-children',
path: 'hide-menu-children',
meta: {
hideChildrenInMenu: true,
icon: 'ic:round-menu',
@ -176,7 +176,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'HideChildrenInMenuChildrenDemo',
path: '/demos/features/hide-menu-children/children',
path: 'children',
component: () =>
import('#/views/demos/features/hide-menu-children/children.vue'),
meta: {
@ -188,7 +188,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FullScreenDemo',
path: '/demos/features/full-screen',
path: 'full-screen',
component: () =>
import('#/views/demos/features/full-screen/index.vue'),
meta: {
@ -198,7 +198,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FileDownloadDemo',
path: '/demos/features/file-download',
path: 'file-download',
component: () =>
import('#/views/demos/features/file-download/index.vue'),
meta: {
@ -208,7 +208,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ClipboardDemo',
path: '/demos/features/clipboard',
path: 'clipboard',
component: () =>
import('#/views/demos/features/clipboard/index.vue'),
meta: {
@ -218,7 +218,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'MenuQueryDemo',
path: '/demos/menu-query',
path: 'menu-query',
component: () =>
import('#/views/demos/features/menu-query/index.vue'),
meta: {
@ -231,7 +231,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'NewWindowDemo',
path: '/demos/new-window',
path: 'new-window',
component: () =>
import('#/views/demos/features/new-window/index.vue'),
meta: {
@ -242,7 +242,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueQueryDemo',
path: '/demos/features/vue-query',
path: 'vue-query',
component: () =>
import('#/views/demos/features/vue-query/index.vue'),
meta: {
@ -252,7 +252,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'RequestParamsSerializerDemo',
path: '/demos/features/request-params-serializer',
path: 'request-params-serializer',
component: () =>
import('#/views/demos/features/request-params-serializer/index.vue'),
meta: {
@ -262,7 +262,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'BigIntDemo',
path: '/demos/features/json-bigint',
path: 'json-bigint',
component: () =>
import('#/views/demos/features/json-bigint/index.vue'),
meta: {
@ -275,7 +275,7 @@ const routes: RouteRecordRaw[] = [
// 面包屑导航
{
name: 'BreadcrumbDemos',
path: '/demos/breadcrumb',
path: 'breadcrumb',
meta: {
icon: 'lucide:navigation',
title: $t('demos.breadcrumb.navigation'),
@ -283,7 +283,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'BreadcrumbLateralDemo',
path: '/demos/breadcrumb/lateral',
path: 'lateral',
component: () => import('#/views/demos/breadcrumb/lateral.vue'),
meta: {
icon: 'lucide:navigation',
@ -292,7 +292,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'BreadcrumbLateralDetailDemo',
path: '/demos/breadcrumb/lateral-detail',
path: 'lateral-detail',
component: () =>
import('#/views/demos/breadcrumb/lateral-detail.vue'),
meta: {
@ -303,7 +303,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'BreadcrumbLevelDemo',
path: '/demos/breadcrumb/level',
path: 'level',
meta: {
icon: 'lucide:navigation',
title: $t('demos.breadcrumb.level'),
@ -311,7 +311,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'BreadcrumbLevelDetailDemo',
path: '/demos/breadcrumb/level/detail',
path: 'detail',
component: () =>
import('#/views/demos/breadcrumb/level-detail.vue'),
meta: {
@ -329,11 +329,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.fallback.title'),
},
name: 'FallbackDemos',
path: '/demos/fallback',
path: 'fallback',
children: [
{
name: 'Fallback403Demo',
path: '/demos/fallback/403',
path: '403',
component: () => import('#/views/_core/fallback/forbidden.vue'),
meta: {
icon: 'mdi:do-not-disturb-alt',
@ -342,7 +342,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Fallback404Demo',
path: '/demos/fallback/404',
path: '404',
component: () => import('#/views/_core/fallback/not-found.vue'),
meta: {
icon: 'mdi:table-off',
@ -351,7 +351,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Fallback500Demo',
path: '/demos/fallback/500',
path: '500',
component: () =>
import('#/views/_core/fallback/internal-error.vue'),
meta: {
@ -361,7 +361,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FallbackOfflineDemo',
path: '/demos/fallback/offline',
path: 'offline',
component: () => import('#/views/_core/fallback/offline.vue'),
meta: {
icon: 'mdi:offline',
@ -379,12 +379,12 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.badge.title'),
},
name: 'BadgeDemos',
path: '/demos/badge',
path: 'badge',
children: [
{
name: 'BadgeDotDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/dot',
path: 'dot',
meta: {
badgeType: 'dot',
icon: 'lucide:square-dot',
@ -394,7 +394,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'BadgeTextDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/text',
path: 'text',
meta: {
badge: '10',
icon: 'lucide:square-dot',
@ -404,7 +404,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'BadgeColorDemo',
component: () => import('#/views/demos/badge/index.vue'),
path: '/demos/badge/color',
path: 'color',
meta: {
badge: 'Hot',
badgeVariants: 'destructive',
@ -422,12 +422,12 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.activeIcon.title'),
},
name: 'ActiveIconDemos',
path: '/demos/active-icon',
path: 'active-icon',
children: [
{
name: 'ActiveIconDemo',
component: () => import('#/views/demos/active-icon/index.vue'),
path: '/demos/active-icon/children',
path: 'children',
meta: {
activeIcon: 'fluent-emoji:radioactive',
icon: 'bi:radioactive',
@ -443,11 +443,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.outside.title'),
},
name: 'OutsideDemos',
path: '/demos/outside',
path: 'outside',
children: [
{
name: 'IframeDemos',
path: '/demos/outside/iframe',
path: 'iframe',
meta: {
icon: 'mdi:newspaper-variant-outline',
title: $t('demos.outside.embedded'),
@ -455,7 +455,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VueDocumentDemo',
path: '/demos/outside/iframe/vue-document',
path: 'vue-document',
component: IFrameView,
meta: {
icon: 'logos:vue',
@ -466,7 +466,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'TailwindcssDemo',
path: '/demos/outside/iframe/tailwindcss',
path: 'tailwindcss',
component: IFrameView,
meta: {
icon: 'devicon:tailwindcss',
@ -479,7 +479,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ExternalLinkDemos',
path: '/demos/outside/external-link',
path: 'external-link',
meta: {
icon: 'mdi:newspaper-variant-multiple-outline',
title: $t('demos.outside.externalLink'),
@ -487,7 +487,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'ViteDemo',
path: '/demos/outside/external-link/vite',
path: 'vite',
component: IFrameView,
meta: {
icon: 'logos:vitejs',
@ -497,7 +497,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueUseDemo',
path: '/demos/outside/external-link/vue-use',
path: 'vue-use',
component: IFrameView,
meta: {
icon: 'logos:vueuse',
@ -516,11 +516,11 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.nested.title'),
},
name: 'NestedDemos',
path: '/demos/nested',
path: 'nested',
children: [
{
name: 'Menu1Demo',
path: '/demos/nested/menu1',
path: 'menu1',
component: () => import('#/views/demos/nested/menu-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -530,7 +530,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu2Demo',
path: '/demos/nested/menu2',
path: 'menu2',
meta: {
icon: 'ic:round-menu',
keepAlive: true,
@ -539,7 +539,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Menu21Demo',
path: '/demos/nested/menu2/menu2-1',
path: 'menu2-1',
component: () => import('#/views/demos/nested/menu-2-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -551,7 +551,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu3Demo',
path: '/demos/nested/menu3',
path: 'menu3',
meta: {
icon: 'ic:round-menu',
title: $t('demos.nested.menu3'),
@ -559,7 +559,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Menu31Demo',
path: '/demos/nested/menu3/menu3-1',
path: 'menu3-1',
component: () => import('#/views/demos/nested/menu-3-1.vue'),
meta: {
icon: 'ic:round-menu',
@ -569,7 +569,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Menu32Demo',
path: '/demos/nested/menu3/menu3-2',
path: 'menu3-2',
meta: {
icon: 'ic:round-menu',
title: $t('demos.nested.menu3_2'),
@ -577,7 +577,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'Menu321Demo',
path: '/demos/nested/menu3/menu3-2/menu3-2-1',
path: 'menu3-2-1',
component: () =>
import('#/views/demos/nested/menu-3-2-1.vue'),
meta: {

93
playground/src/router/routes/modules/examples.ts

@ -15,7 +15,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'FormExample',
path: '/examples/form',
path: 'form',
meta: {
icon: 'mdi:form-select',
title: $t('examples.form.title'),
@ -23,7 +23,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'FormBasicExample',
path: '/examples/form/basic',
path: 'basic',
component: () => import('#/views/examples/form/basic.vue'),
meta: {
title: $t('examples.form.basic'),
@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormQueryExample',
path: '/examples/form/query',
path: 'query',
component: () => import('#/views/examples/form/query.vue'),
meta: {
title: $t('examples.form.query'),
@ -39,7 +39,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormValueFormatExample',
path: '/examples/form/value-format',
path: 'value-format',
component: () => import('#/views/examples/form/value-format.vue'),
meta: {
title: $t('examples.form.valueFormat'),
@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormRulesExample',
path: '/examples/form/rules',
path: 'rules',
component: () => import('#/views/examples/form/rules.vue'),
meta: {
title: $t('examples.form.rules'),
@ -55,7 +55,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormDynamicExample',
path: '/examples/form/dynamic',
path: 'dynamic',
component: () => import('#/views/examples/form/dynamic.vue'),
meta: {
title: $t('examples.form.dynamic'),
@ -63,7 +63,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormLayoutExample',
path: '/examples/form/custom-layout',
path: 'custom-layout',
component: () => import('#/views/examples/form/custom-layout.vue'),
meta: {
title: $t('examples.form.layout'),
@ -71,7 +71,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormCustomExample',
path: '/examples/form/custom',
path: 'custom',
component: () => import('#/views/examples/form/custom.vue'),
meta: {
title: $t('examples.form.custom'),
@ -79,7 +79,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormApiExample',
path: '/examples/form/api',
path: 'api',
component: () => import('#/views/examples/form/api.vue'),
meta: {
title: $t('examples.form.api'),
@ -87,7 +87,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormMergeExample',
path: '/examples/form/merge',
path: 'merge',
component: () => import('#/views/examples/form/merge.vue'),
meta: {
title: $t('examples.form.merge'),
@ -95,7 +95,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormScrollToErrorExample',
path: '/examples/form/scroll-to-error-test',
path: 'scroll-to-error-test',
component: () =>
import('#/views/examples/form/scroll-to-error-test.vue'),
meta: {
@ -104,17 +104,26 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'FormCollapsibleExample',
path: '/examples/form/collapsible-test',
path: 'collapsible-test',
component: () => import('#/views/examples/form/collapsible.vue'),
meta: {
title: $t('examples.form.collapsible'),
},
},
{
name: 'FormArrayDemo',
path: '/form-array-demo',
component: () => import('#/views/demos/form-array/index.vue'),
meta: {
icon: 'lucide:list-plus',
title: '表单数组 Demo',
},
},
],
},
{
name: 'VxeTableExample',
path: '/examples/vxe-table',
path: 'vxe-table',
meta: {
icon: 'lucide:table',
title: $t('examples.vxeTable.title'),
@ -122,7 +131,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VxeTableBasicExample',
path: '/examples/vxe-table/basic',
path: 'basic',
component: () => import('#/views/examples/vxe-table/basic.vue'),
meta: {
title: $t('examples.vxeTable.basic'),
@ -130,7 +139,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableRemoteExample',
path: '/examples/vxe-table/remote',
path: 'remote',
component: () => import('#/views/examples/vxe-table/remote.vue'),
meta: {
title: $t('examples.vxeTable.remote'),
@ -138,7 +147,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableTreeExample',
path: '/examples/vxe-table/tree',
path: 'tree',
component: () => import('#/views/examples/vxe-table/tree.vue'),
meta: {
title: $t('examples.vxeTable.tree'),
@ -146,7 +155,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableFixedExample',
path: '/examples/vxe-table/fixed',
path: 'fixed',
component: () => import('#/views/examples/vxe-table/fixed.vue'),
meta: {
title: $t('examples.vxeTable.fixed'),
@ -154,7 +163,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableCustomCellExample',
path: '/examples/vxe-table/custom-cell',
path: 'custom-cell',
component: () =>
import('#/views/examples/vxe-table/custom-cell.vue'),
meta: {
@ -163,7 +172,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableFormExample',
path: '/examples/vxe-table/form',
path: 'form',
component: () => import('#/views/examples/vxe-table/form.vue'),
meta: {
title: $t('examples.vxeTable.form'),
@ -171,7 +180,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableEditCellExample',
path: '/examples/vxe-table/edit-cell',
path: 'edit-cell',
component: () => import('#/views/examples/vxe-table/edit-cell.vue'),
meta: {
title: $t('examples.vxeTable.editCell'),
@ -179,7 +188,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableEditRowExample',
path: '/examples/vxe-table/edit-row',
path: 'edit-row',
component: () => import('#/views/examples/vxe-table/edit-row.vue'),
meta: {
title: $t('examples.vxeTable.editRow'),
@ -187,7 +196,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableVirtualExample',
path: '/examples/vxe-table/virtual',
path: 'virtual',
component: () => import('#/views/examples/vxe-table/virtual.vue'),
meta: {
title: $t('examples.vxeTable.virtual'),
@ -195,7 +204,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VxeTableViewedExample',
path: '/examples/vxe-table/viewed',
path: 'viewed',
component: () => import('#/views/examples/vxe-table/viewed.vue'),
meta: {
title: $t('examples.vxeTable.viewed'),
@ -205,7 +214,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'CaptchaExample',
path: '/examples/captcha',
path: 'captcha',
meta: {
icon: 'logos:recaptcha',
title: $t('examples.captcha.title'),
@ -213,7 +222,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'DragVerifyExample',
path: '/examples/captcha/slider',
path: 'slider',
component: () =>
import('#/views/examples/captcha/slider-captcha.vue'),
meta: {
@ -222,7 +231,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'RotateVerifyExample',
path: '/examples/captcha/slider-rotate',
path: 'slider-rotate',
component: () =>
import('#/views/examples/captcha/slider-rotate-captcha.vue'),
meta: {
@ -231,7 +240,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'TranslateVerifyExample',
path: '/examples/captcha/slider-translate',
path: 'slider-translate',
component: () =>
import('#/views/examples/captcha/slider-translate-captcha.vue'),
meta: {
@ -240,7 +249,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'CaptchaPointSelectionExample',
path: '/examples/captcha/point-selection',
path: 'point-selection',
component: () =>
import('#/views/examples/captcha/point-selection-captcha.vue'),
meta: {
@ -251,7 +260,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ModalExample',
path: '/examples/modal',
path: 'modal',
component: () => import('#/views/examples/modal/index.vue'),
meta: {
icon: 'system-uicons:window-content',
@ -261,7 +270,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'DrawerExample',
path: '/examples/drawer',
path: 'drawer',
component: () => import('#/views/examples/drawer/index.vue'),
meta: {
icon: 'iconoir:drawer',
@ -271,7 +280,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'EllipsisExample',
path: '/examples/ellipsis',
path: 'ellipsis',
component: () => import('#/views/examples/ellipsis/index.vue'),
meta: {
icon: 'ion:ellipsis-horizontal',
@ -280,7 +289,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VueResizeDemo',
path: '/demos/resize/basic',
path: 'resize/basic',
component: () => import('#/views/examples/resize/basic.vue'),
meta: {
icon: 'material-symbols:resize',
@ -289,7 +298,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ColPageDemo',
path: '/examples/layout/col-page',
path: 'layout/col-page',
component: () => import('#/views/examples/layout/col-page.vue'),
meta: {
badge: 'Alpha',
@ -300,7 +309,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'TippyDemo',
path: '/examples/tippy',
path: 'tippy',
component: () => import('#/views/examples/tippy/index.vue'),
meta: {
icon: 'mdi:message-settings-outline',
@ -309,7 +318,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'JsonViewer',
path: '/examples/json-viewer',
path: 'json-viewer',
component: () => import('#/views/examples/json-viewer/index.vue'),
meta: {
icon: 'tabler:json',
@ -318,7 +327,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Motion',
path: '/examples/motion',
path: 'motion',
component: () => import('#/views/examples/motion/index.vue'),
meta: {
icon: 'mdi:animation-play',
@ -327,7 +336,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'CountTo',
path: '/examples/count-to',
path: 'count-to',
component: () => import('#/views/examples/count-to/index.vue'),
meta: {
icon: 'mdi:animation-play',
@ -336,7 +345,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'Loading',
path: '/examples/loading',
path: 'loading',
component: () => import('#/views/examples/loading/index.vue'),
meta: {
icon: 'mdi:circle-double',
@ -345,7 +354,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ButtonGroup',
path: '/examples/button-group',
path: 'button-group',
component: () => import('#/views/examples/button-group/index.vue'),
meta: {
icon: 'mdi:check-circle',
@ -354,7 +363,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'ContextMenu',
path: '/examples/context-menu',
path: 'context-menu',
component: () => import('#/views/examples/context-menu/index.vue'),
meta: {
icon: 'mdi:menu',
@ -363,7 +372,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'CropperDemo',
path: '/examples/cropper',
path: 'cropper',
component: () => import('#/views/examples/cropper/index.vue'),
meta: {
icon: 'mdi:crop',
@ -372,7 +381,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'TiptapExample',
path: '/examples/tiptap',
path: 'tiptap',
component: () => import('#/views/examples/tiptap/index.vue'),
meta: {
icon: 'lucide:square-pen',

8
playground/src/router/routes/modules/system.ts

@ -13,7 +13,7 @@ const routes: RouteRecordRaw[] = [
path: '/system',
children: [
{
path: '/system/user',
path: 'user',
name: 'SystemUser',
meta: {
icon: 'mdi:user',
@ -22,7 +22,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/system/user/list.vue'),
},
{
path: '/system/role',
path: 'role',
name: 'SystemRole',
meta: {
icon: 'mdi:account-group',
@ -31,7 +31,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/system/role/list.vue'),
},
{
path: '/system/menu',
path: 'menu',
name: 'SystemMenu',
meta: {
icon: 'mdi:menu',
@ -40,7 +40,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/system/menu/list.vue'),
},
{
path: '/system/dept',
path: 'dept',
name: 'SystemDept',
meta: {
icon: 'charm:organisation',

14
playground/src/router/routes/modules/vben.ts

@ -32,7 +32,7 @@ const routes: RouteRecordRaw[] = [
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
path: 'document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
@ -42,7 +42,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
path: 'github',
component: IFrameView,
meta: {
icon: 'mdi:github',
@ -52,7 +52,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdv',
path: '/vben-admin/antdv',
path: 'antdv',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -63,7 +63,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenAntdVNext',
path: '/vben-admin/antdv-next',
path: 'antdv-next',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -75,7 +75,7 @@ const routes: RouteRecordRaw[] = [
{
name: 'VbenNaive',
path: '/vben-admin/naive',
path: 'naive',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenElementPlus',
path: '/vben-admin/ele',
path: 'ele',
component: IFrameView,
meta: {
badgeType: 'dot',
@ -97,7 +97,7 @@ const routes: RouteRecordRaw[] = [
},
{
name: 'VbenTDesign',
path: '/vben-admin/tdesign',
path: 'tdesign',
component: IFrameView,
meta: {
badgeType: 'dot',

325
playground/src/views/demos/form-array/README.md

@ -0,0 +1,325 @@
# Form Array 数组字段用法
这个 demo 展示 `form-ui` 的数组字段 schema 写法。业务侧推荐使用 `type: 'array' + children` 描述数组结构,不需要直接指定 `component: 'VbenFormFieldArray'`。
示例页面:
- `/vue-vben-admin/playground/src/views/demos/form-array/index.vue`
核心实现:
- `/packages/@core/ui-kit/form-ui/src/form-render/schema.ts`
- `/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue`
- `/packages/@core/ui-kit/form-ui/src/form-api.ts`
## 快速开始
```ts
const schema: VbenFormSchema[] = [
{
type: 'array',
fieldName: 'contacts',
label: '联系人',
formItemClass: 'col-span-1 md:col-span-2',
defaultValue: [
{
enabled: true,
name: '张三',
phone: '10086',
role: 'owner',
},
],
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
arrayProps: {
addButtonText: '添加联系人',
min: 1,
max: 5,
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
},
children: [
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
},
{
component: 'Select',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
],
},
},
],
},
];
```
## 字段职责
### `type: 'array'`
声明这是一个数组字段。渲染前会被内部转换为 `VbenFormFieldArray`,所以业务 schema 不需要写具体组件名。
### `fieldName`
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
```ts
contacts[0].name
```
### `children`
数组每一行的子字段定义。每个 child 都是完整的 `FormSchema`,可以继续使用:
- `component`
- `componentProps`
- `rules`
- `dependencies`
- `defaultValue`
- `help`
- `suffix`
- `renderComponentContent`
- `valueFormat`
- `formFieldProps`
- `disabled`
- `hide`
- `labelClass`
- `controlClass`
### `arrayProps`
传给数组编辑器的配置:
| 字段 | 说明 |
| --- | --- |
| `addButtonText` | 新增按钮文案 |
| `actionText` | 操作列表头文案 |
| `emptyText` | 空数据文案 |
| `min` | 最少行数,达到后禁用删除 |
| `max` | 最多行数,达到后禁用新增 |
| `showIndex` | 是否显示序号 |
| `createRow` | 新增行时生成默认数据 |
## 校验建议
数组父级 `rules` 建议只写数组级规则,例如至少一行:
```ts
rules: z.array(z.any()).min(1, '请至少添加一个联系人')
```
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`:
```ts
{
component: 'Input',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
}
```
不要在父级数组规则里重复写 `z.object({ name: ... })`,否则某一行子字段失败时,父级数组也会失败,容易出现重复错误提示。
## dependencies 用法
children 里的 `dependencies.triggerFields` 默认是“当前行相对路径”。例如:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
dependencies: {
triggerFields: ['role'],
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
},
}
```
在第 1 行中,`triggerFields: ['role']` 会被转换成:
```ts
contacts[0].role
```
回调多了一个可选 `ctx` 参数:
| 字段 | 说明 |
| --- | --- |
| `ctx.row` | 当前行数据 |
| `ctx.rowIndex` | 当前行索引 |
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
| `ctx.arrayField` | 数组字段名,例如 `contacts` |
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
| `ctx.rootValues` | 表单完整值 |
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
```ts
dependencies: {
triggerFields: ['$root.planName'],
componentProps: (values) => ({
disabled: !values.planName,
}),
}
```
如果想显式写当前行字段,也可以使用 `$row.` 前缀:
```ts
triggerFields: ['$row.role']
```
## valueFormat 用法
children 里的 `valueFormat` 也会按行执行:
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
}
```
在 `contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
```ts
contacts[0].phone
```
如果要写根字段,用 `$root.`:
```ts
setValue('$root.firstContactPhone', value)
```
如果要显式写当前行字段,用 `$row.`:
```ts
setValue('$row.phone', value)
```
## updateSchema 用法
可以用父级路径更新 child schema:
```ts
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
如果传入带索引路径,也会更新对应 child 定义:
```ts
formApi.updateSchema([
{
fieldName: 'contacts[0].phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
```
注意:`updateSchema` 更新的是 schema 定义,不是单行实例。因此 `contacts[0].phone` 这种写法目前会解析到 child `phone`,实际影响所有行的该列。
## 新增行默认值
优先使用 `arrayProps.createRow`:
```ts
arrayProps: {
createRow: () => ({
name: '',
role: 'member',
phone: '',
enabled: true,
}),
}
```
没有 `createRow` 时,会根据 children 的 `defaultValue` 生成行数据;没有 defaultValue 的字段会给 `null`。
## 内部流程
数组字段从 schema 到渲染大致是这条链路:
```mermaid
flowchart TD
A["业务 schema: type='array' + children"] --> B["form.vue computedSchema"]
B --> C["createFormFieldSchema"]
C --> D["识别数组 schema"]
D --> E["转换为 component='VbenFormFieldArray'"]
E --> F["children 放入 componentProps.schema"]
F --> G["form-field.vue 渲染外层 FormField"]
G --> H["form-field-array.vue useFieldArray 管理行"]
H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render/valueFormat ctx"]
J --> M["继续复用 FormField 渲染 child"]
```
几个关键点:
- 外层 `type: 'array'` 只是语义声明。
- 具体展示仍复用内部 `VbenFormFieldArray`。
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
- `valueFormat` 和 `updateSchema` 在 `FormApi` 里递归处理 children。
## 小屏幕展示
`form-field-array` 在大屏下按表格式 grid 展示,在小屏下每行转为纵向堆叠,并显示每个 child 的 label。业务侧通常不需要额外处理移动端布局。
## 兼容旧写法
旧写法仍可用:
```ts
{
component: 'VbenFormFieldArray',
fieldName: 'contacts',
componentProps: {
schema: [...],
},
}
```
新代码推荐:
```ts
{
type: 'array',
fieldName: 'contacts',
children: [...],
}
```

187
playground/src/views/demos/form-array/index.vue

@ -0,0 +1,187 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({});
const schema: VbenFormSchema[] = [
{
component: 'Input',
componentProps: {
placeholder: '请输入方案名称',
},
defaultValue: '值班联络人配置',
fieldName: 'planName',
label: '方案名称',
rules: z.string().min(1, '请输入方案名称'),
},
{
component: 'Textarea',
dependencies: {
componentProps: (values) => {
const planName = values.planName as string | undefined;
return {
disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
rows: 2,
};
},
required: (values) => {
return String(values.planName ?? '').includes('值班');
},
rules: (values) => {
return String(values.planName ?? '').includes('值班')
? z.string().min(2, '请输入至少 2 个字')
: z.string().optional();
},
triggerFields: ['planName'],
},
fieldName: 'description',
formItemClass: 'col-span-1 md:col-span-2',
label: '方案说明',
},
{
arrayProps: {
addButtonText: '添加联系人',
createRow: () => ({
enabled: true,
name: '',
phone: '',
role: 'member',
}),
max: 5,
min: 1,
},
children: [
{
component: 'Input',
componentProps: (_values, _form, ctx) => ({
placeholder: `第 ${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
valueFormat: (value) => value?.trim(),
},
{
component: 'Select',
componentProps: {
options: [
{ label: '负责人', value: 'owner' },
{ label: '成员', value: 'member' },
{ label: '观察员', value: 'viewer' },
],
},
defaultValue: 'member',
fieldName: 'role',
label: '角色',
rules: 'selectRequired',
},
{
component: 'Input',
dependencies: {
componentProps: (_values, _form, _api, ctx) => ({
disabled: ctx?.row?.role === 'viewer',
placeholder:
ctx?.row?.role === 'viewer' ? '观察员无需电话' : '请输入电话',
}),
triggerFields: ['role'],
},
fieldName: 'phone',
label: '电话',
rules: z.string().optional(),
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
},
{
component: 'Switch',
componentProps: {
checkedChildren: '启用',
unCheckedChildren: '停用',
},
defaultValue: true,
fieldName: 'enabled',
label: '状态',
},
],
defaultValue: [
{
enabled: true,
name: '张三',
phone: ' 10086 ',
role: 'owner',
},
],
fieldName: 'contacts',
formItemClass: 'col-span-1 md:col-span-2',
label: '联系人',
rules: z.array(z.any()).min(1, '请至少添加一个联系人'),
type: 'array',
},
];
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 90,
},
handleSubmit: (values) => {
submitValues.value = values;
message.success('已通过校验');
},
schema,
showDefaultActions: false,
wrapperClass: 'grid-cols-1 gap-x-4 md:grid-cols-2',
});
async function handleSubmit() {
await formApi.validateAndSubmitForm();
}
async function handleGetValues() {
submitValues.value = await formApi.getValues();
}
function handlePatchChildRule() {
formApi.updateSchema([
{
fieldName: 'contacts.phone',
rules: z.string().min(5, '电话至少 5 位'),
},
]);
message.success('已动态更新子字段规则');
}
</script>
<template>
<Page title="Form Array Demo">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card title="数组字段">
<Form />
<Space class="mt-4 flex flex-wrap">
<Button type="primary" @click="handleSubmit">提交</Button>
<Button @click="handleGetValues">获取值</Button>
<Button @click="handlePatchChildRule">更新电话规则</Button>
</Space>
</Card>
<Card title="输出">
<pre
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
</Card>
</div>
</Page>
</template>
Loading…
Cancel
Save