diff --git a/apps/backend-mock/routes/[...].ts b/apps/backend-mock/routes/[...].ts index 5a22563dc..44038cf7c 100644 --- a/apps/backend-mock/routes/[...].ts +++ b/apps/backend-mock/routes/[...].ts @@ -3,13 +3,13 @@ import { defineEventHandler } from 'h3'; export default defineEventHandler(() => { return `

Hello Vben Admin

-

Mock service is starting

+

Mock service is running

`; }); diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index ee38e8ef9..84c416deb 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/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'], diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index 81b6e8fa3..8a1cadb46 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/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, diff --git a/apps/web-antd/src/router/routes/modules/dashboard.ts b/apps/web-antd/src/router/routes/modules/dashboard.ts index 5254dc65d..44f15ef9a 100644 --- a/apps/web-antd/src/router/routes/modules/dashboard.ts +++ b/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', diff --git a/apps/web-antd/src/router/routes/modules/demos.ts b/apps/web-antd/src/router/routes/modules/demos.ts index 55ade09c9..0f9a0a939 100644 --- a/apps/web-antd/src/router/routes/modules/demos.ts +++ b/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'), }, ], diff --git a/apps/web-antd/src/router/routes/modules/vben.ts b/apps/web-antd/src/router/routes/modules/vben.ts index 283d83e53..abcbee3e7 100644 --- a/apps/web-antd/src/router/routes/modules/vben.ts +++ b/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', diff --git a/apps/web-antdv-next/src/adapter/component/index.ts b/apps/web-antdv-next/src/adapter/component/index.ts index ebd6909b1..4a505fdbc 100644 --- a/apps/web-antdv-next/src/adapter/component/index.ts +++ b/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, diff --git a/apps/web-antdv-next/src/router/routes/modules/dashboard.ts b/apps/web-antdv-next/src/router/routes/modules/dashboard.ts index 5254dc65d..44f15ef9a 100644 --- a/apps/web-antdv-next/src/router/routes/modules/dashboard.ts +++ b/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', diff --git a/apps/web-antdv-next/src/router/routes/modules/demos.ts b/apps/web-antdv-next/src/router/routes/modules/demos.ts index 4605d91cb..18b756c7f 100644 --- a/apps/web-antdv-next/src/router/routes/modules/demos.ts +++ b/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'), }, ], diff --git a/apps/web-antdv-next/src/router/routes/modules/vben.ts b/apps/web-antdv-next/src/router/routes/modules/vben.ts index 96f741ef9..1f3e238b5 100644 --- a/apps/web-antdv-next/src/router/routes/modules/vben.ts +++ b/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', diff --git a/apps/web-ele/src/router/routes/modules/dashboard.ts b/apps/web-ele/src/router/routes/modules/dashboard.ts index 5254dc65d..44f15ef9a 100644 --- a/apps/web-ele/src/router/routes/modules/dashboard.ts +++ b/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', diff --git a/apps/web-ele/src/router/routes/modules/demos.ts b/apps/web-ele/src/router/routes/modules/demos.ts index 907ea3f45..b73d14bd8 100644 --- a/apps/web-ele/src/router/routes/modules/demos.ts +++ b/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'), }, ], diff --git a/apps/web-ele/src/router/routes/modules/vben.ts b/apps/web-ele/src/router/routes/modules/vben.ts index 1e8176ea0..da15417a9 100644 --- a/apps/web-ele/src/router/routes/modules/vben.ts +++ b/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', diff --git a/apps/web-naive/src/router/routes/modules/dashboard.ts b/apps/web-naive/src/router/routes/modules/dashboard.ts index 5254dc65d..44f15ef9a 100644 --- a/apps/web-naive/src/router/routes/modules/dashboard.ts +++ b/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', diff --git a/apps/web-naive/src/router/routes/modules/demos.ts b/apps/web-naive/src/router/routes/modules/demos.ts index 9a4ffeff6..b6d33c400 100644 --- a/apps/web-naive/src/router/routes/modules/demos.ts +++ b/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'), }, ], diff --git a/apps/web-naive/src/router/routes/modules/vben.ts b/apps/web-naive/src/router/routes/modules/vben.ts index 888ff4cb4..3048c25f2 100644 --- a/apps/web-naive/src/router/routes/modules/vben.ts +++ b/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', diff --git a/apps/web-tdesign/src/router/routes/modules/dashboard.ts b/apps/web-tdesign/src/router/routes/modules/dashboard.ts index 5254dc65d..44f15ef9a 100644 --- a/apps/web-tdesign/src/router/routes/modules/dashboard.ts +++ b/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', diff --git a/apps/web-tdesign/src/router/routes/modules/demos.ts b/apps/web-tdesign/src/router/routes/modules/demos.ts index 9ce8ec992..527f9b251 100644 --- a/apps/web-tdesign/src/router/routes/modules/demos.ts +++ b/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'), }, ], diff --git a/apps/web-tdesign/src/router/routes/modules/vben.ts b/apps/web-tdesign/src/router/routes/modules/vben.ts index db555083f..cb125e33d 100644 --- a/apps/web-tdesign/src/router/routes/modules/vben.ts +++ b/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', diff --git a/docs/src/en/guide/essentials/route.md b/docs/src/en/guide/essentials/route.md index 8fb0a6d1c..71540b2a5 100644 --- a/docs/src/en/guide/essentials/route.md +++ b/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'), }, }, ]; diff --git a/docs/src/en/guide/essentials/server.md b/docs/src/en/guide/essentials/server.md index b17db80dd..770a0e453 100644 --- a/docs/src/en/guide/essentials/server.md +++ b/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 diff --git a/docs/src/en/guide/essentials/settings.md b/docs/src/en/guide/essentials/settings.md index 3bbc4383b..5c934dfa1 100644 --- a/docs/src/en/guide/essentials/settings.md +++ b/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, diff --git a/docs/src/en/index.md b/docs/src/en/index.md index 42aeef3ff..a91cc4803 100644 --- a/docs/src/en/index.md +++ b/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 --- diff --git a/docs/src/guide/essentials/route.md b/docs/src/guide/essentials/route.md index 8383e7620..9668e8d54 100644 --- a/docs/src/guide/essentials/route.md +++ b/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'), }, }, ]; diff --git a/docs/src/guide/essentials/server.md b/docs/src/guide/essentials/server.md index 2ae16e763..304c500ee 100644 --- a/docs/src/guide/essentials/server.md +++ b/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 服务 diff --git a/docs/src/guide/essentials/settings.md b/docs/src/guide/essentials/settings.md index 32111d4f1..ec50b18b4 100644 --- a/docs/src/guide/essentials/settings.md +++ b/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, diff --git a/docs/src/index.md b/docs/src/index.md index 3a346d04c..82703af5e 100644 --- a/docs/src/index.md +++ b/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: 官方站点 --- diff --git a/internal/lint-configs/eslint-config/src/configs/javascript.ts b/internal/lint-configs/eslint-config/src/configs/javascript.ts index bd9bd852b..0cab71f11 100644 --- a/internal/lint-configs/eslint-config/src/configs/javascript.ts +++ b/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 { ...recommendedRules, 'dot-notation': ['error', { allowKeywords: true }], 'keyword-spacing': 'off', - 'no-control-regex': 'error', 'no-empty-function': 'off', 'no-octal': 'error', 'no-octal-escape': 'error', diff --git a/internal/lint-configs/oxlint-config/src/configs/javascript.ts b/internal/lint-configs/oxlint-config/src/configs/javascript.ts index 4352c8017..89784dc36 100644 --- a/internal/lint-configs/oxlint-config/src/configs/javascript.ts +++ b/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': [ diff --git a/internal/lint-configs/oxlint-config/src/configs/vue.ts b/internal/lint-configs/oxlint-config/src/configs/vue.ts index 172507d69..6e140b85d 100644 --- a/internal/lint-configs/oxlint-config/src/configs/vue.ts +++ b/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', }, }; diff --git a/internal/node-utils/src/git.ts b/internal/node-utils/src/git.ts index 88f159cc5..da6dc74cc 100644 --- a/internal/node-utils/src/git.ts +++ b/internal/node-utils/src/git.ts @@ -20,7 +20,13 @@ async function getStagedFiles(): Promise { '-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(''); diff --git a/packages/@core/base/shared/src/utils/tree.ts b/packages/@core/base/shared/src/utils/tree.ts index f3056dcf5..1a8a2ecab 100644 --- a/packages/@core/base/shared/src/utils/tree.ts +++ b/packages/@core/base/shared/src/utils/tree.ts @@ -79,16 +79,22 @@ function filterTree>( */ function mapTree>( 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 = mapper(node); + const mapperNode: Record = 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; }); diff --git a/packages/@core/base/typings/src/basic.d.ts b/packages/@core/base/typings/src/basic.d.ts index 35ed70954..629999ebc 100644 --- a/packages/@core/base/typings/src/basic.d.ts +++ b/packages/@core/base/typings/src/basic.d.ts @@ -8,6 +8,7 @@ type SelectOption = BasicOption; type TabOption = BasicOption; interface BasicUserInfo { + [key: string]: any; /** * 头像 */ diff --git a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap index 6a74bb4f6..7746430a7 100644 --- a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap +++ b/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": { diff --git a/packages/@core/preferences/src/config.ts b/packages/@core/preferences/src/config.ts index d78d97e04..9160a8705 100644 --- a/packages/@core/preferences/src/config.ts +++ b/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, diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index a4c05b54e..50ae2b754 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/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' }, diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index 1d7a0730a..27e155429 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -1,5 +1,5 @@ diff --git a/packages/effects/access/src/__tests__/accessible.test.ts b/packages/effects/access/src/__tests__/accessible.test.ts new file mode 100644 index 000000000..f059ff100 --- /dev/null +++ b/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'); + }); +}); diff --git a/packages/effects/access/src/accessible.ts b/packages/effects/access/src/accessible.ts index fc0873657..f3d19fd8d 100644 --- a/packages/effects/access/src/accessible.ts +++ b/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; }); diff --git a/packages/effects/layouts/src/basic/content/content.vue b/packages/effects/layouts/src/basic/content/content.vue index 2d6bb916a..f7ab347db 100644 --- a/packages/effects/layouts/src/basic/content/content.vue +++ b/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" > { 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) { diff --git a/packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue b/packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue index d9726e692..7d0458ebf 100644 --- a/packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue +++ b/packages/effects/layouts/src/widgets/preferences/blocks/switch-item.vue @@ -36,7 +36,7 @@ function handleClick() {