From e416de3290e0c461dfd3dd9bb6325da0f1869efa Mon Sep 17 00:00:00 2001 From: Harold Zhang Date: Fri, 10 Jul 2026 10:15:40 +0800 Subject: [PATCH] fix: fixed defects of router --- .../access/src/__tests__/accessible.test.ts | 240 ++++++++++++++++++ packages/effects/access/src/accessible.ts | 19 +- 2 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/effects/access/src/__tests__/accessible.test.ts 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 00000000..f059ff10 --- /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 10e94282..f3d19fd8 100644 --- a/packages/effects/access/src/accessible.ts +++ b/packages/effects/access/src/accessible.ts @@ -148,11 +148,26 @@ async function generateRoutes( return route; } - if (parent && parent.redirect) { - const parentSplit = (parent.redirect as string).split('/'); + // 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}`; }