动态路由菜单生成
从真实后端菜单响应适配到 Admin Layouts 动态菜单运行时
动态模式不是把后端 JSON 直接塞给布局包。真实后端返回的是业务菜单响应,当前项目再把它适配成 AdminLayoutsDynamicRoutes,最后由 @skyroc/web-admin-layouts 生成菜单树、快速索引和首页。
当前边界是:
| 层级 | 数据形状 | 职责 |
|---|---|---|
| 后端接口 | BackendRouteResponse | 返回用户首页和授权菜单树,字段接近 name/path/component/handle。 |
apps/admin 适配层 | AdminLayoutsDynamicRoutes | 规范化路径、过滤不存在的前端页面、把 handle 映射成布局 meta。 |
@skyroc/web-admin-layouts | Api.Route.BackendRoute[] | 生成 GeneratedMenu、quickReferenceMenus 和动态首页。 |
| TanStack Router | routeTree | 提供真实 React 页面组件和 route match。 |
启动接入
应用启动时只把适配后的加载器交给布局包:
import { loadAdminDynamicRoutes } from '@/features/menus/dynamic-routes';
setupAdminLayouts({
defaultHome: globalConfig.defaultHome,
defaultIcon: globalConfig.defaultIcon,
loadDynamicRoutes: loadAdminDynamicRoutes,
menuCategories,
extras: menuExtras,
menuNodeCallback,
permissionSuperRole: import.meta.env.VITE_STATIC_SUPER_ROLE,
routeMode: globalConfig.routeMode,
routeTree,
storage: localStg
});loadAdminDynamicRoutes() 内部才会调用:
queryClient.ensureQueryData(queryMenusOptions())这样 queryMenusOptions() 仍然表示真实后端接口缓存,loadDynamicRoutes 表示布局运行时需要的动态菜单数据。
真实后端响应
当前 /route/getReactUserRoutes 返回的结构是:
interface BackendRouteResponse {
home?: string | null;
routes: BackendRoutePayload[];
}
interface BackendRoutePayload {
children?: BackendRoutePayload[] | null;
component?: string | null;
handle?: BackendRouteHandle | null;
id?: number | string | null;
layout?: string | null;
meta?: BackendRouteHandle | null;
name?: string | null;
parentId?: number | string | null;
path: string;
redirect?: string | null;
}典型响应类似:
{
"home": "/home",
"routes": [
{
"name": "manage",
"path": "/manage",
"component": "page.(base)_manage",
"redirect": "/manage/role",
"handle": {
"title": "manage",
"i18nKey": "route.(base)_manage",
"icon": "carbon:cloud-service-management",
"order": 8,
"roles": ["R_ADMIN"]
},
"children": [
{
"name": "manage_user",
"path": "/manage/user",
"component": "page.(base)_manage_user",
"handle": {
"title": "manage_user",
"i18nKey": "route.(base)_manage_user",
"icon": "ic:round-manage-accounts",
"order": 1,
"roles": ["R_ADMIN"],
"keepAlive": true
},
"children": [
{
"name": "manage_user_[id]",
"path": "/manage/user/:id",
"component": "page.(base)_manage_user_[id]",
"handle": {
"title": "(base)_manage_user_[id]",
"i18nKey": "route.(base)_manage_user_[id]",
"hideInMenu": true,
"activeMenu": "/manage/user"
}
}
]
}
]
}
]
}这不是布局包最终消费的结构。component、redirect、name、handle 都是接口层字段,需要应用侧转换。
适配后结构
布局包消费的是:
interface AdminLayoutsDynamicRoutes {
home?: Router.RoutePath;
routes: Api.Route.BackendRoute[];
}
interface BackendRoute extends Router.Meta {
children?: BackendRoute[];
id: string;
layout?: Router.MenuCategoryKey;
parentId?: string | null;
path: Router.RoutePath;
}apps/admin 的适配规则:
| 后端字段 | 适配后字段 |
|---|---|
home | home,但只保留当前 routeTree 中存在的路径。 |
path | path,会去掉尾部 /,并把 :id 转成 TanStack Router 的 $id。 |
name / id | id,优先后端 id,否则使用 name,最后回退到 path。 |
layout | layout,如果后端返回,按菜单分类 key 处理,例如 admin。 |
handle.title | title。 |
handle.i18nKey | i18nKey。 |
handle.roles | permissions。 |
handle.href | href。 |
handle.url | url。 |
handle.keepAlive | keepAlive。 |
handle.fixedIndexInTab | tab.fixedIndex。 |
handle.multiTab | tab.multi。 |
handle.icon | menu.icon。 |
handle.localIcon | menu.localIcon。 |
handle.order | menu.order。 |
handle.hideInMenu | menu.hide。 |
handle.activeMenu | menu.activeMenu,同样要能匹配当前 routeTree。 |
handle.badge | menu.badge。 |
handle.extra | menu.extra,必须是已注册的 extra key。 |
后端 component 不会被布局包直接渲染。当前项目的 React 页面仍然来自前端 apps/admin/src/pages 和生成的 routeTree。
路径规则
动态菜单能不能工作,关键看 path 是否能匹配前端 route。
| 后端可能返回 | 适配后 | 前端需要存在 |
|---|---|---|
/home | /home | apps/admin/src/pages/(admin)/home/index.tsx |
/manage/user | /manage/user | apps/admin/src/pages/(admin)/manage/user/index.tsx |
/manage/user/:id | /manage/user/$id | apps/admin/src/pages/(admin)/manage/user/$id.tsx |
/manage/user/1 | 不应作为菜单协议返回 | 这是实际 URL,不是 route pattern。 |
/(admin)/home | 不应返回 | (admin) 是前端 route group,不进入 URL。 |
适配层会过滤不在当前 routeTree 中的节点。原因是动态菜单只决定“哪些已存在的前端页面可见、可访问”,不能让后端临时创造一个 React 页面。
权限边界
动态模式下,菜单可见性应由后端先裁剪,前端也会做同一套兜底过滤。适配层会把 handle.roles 映射成 permissions,布局包生成动态菜单和 quickReferenceMenus 时会结合当前用户角色判断权限:
用户直接访问 /manage/user/1
-> TanStack Router match 到 /manage/user/$id
-> hasAuthorizedRoutePath('/manage/user/$id', userInfo)
-> quickReferenceMenus 中存在且 permissions 通过才允许进入因此:
- 后端没返回某个 route pattern,直接访问会进 403。
- 后端返回了 route pattern,但
roles不匹配,菜单不会出现,直接访问也会进 403。 permissionSuperRole对静态、动态两种模式都生效。
隐藏页
后端详情页可以返回:
{
"name": "manage_user_[id]",
"path": "/manage/user/:id",
"handle": {
"title": "user detail",
"hideInMenu": true,
"activeMenu": "/manage/user"
}
}适配后会进入 quickReferenceMenus,但不会显示在菜单树里。这样详情页仍能参与直接访问授权、tab 标题、面包屑和父菜单高亮。
生成流程
useMenus().initMenus(userInfo)
├─ loadAdminDynamicRoutes()
│ ├─ queryClient.ensureQueryData(queryMenusOptions())
│ └─ normalizeBackendRouteResponse(rawResponse)
└─ menuGenerator.generate({ backendRoutes, home, userInfo })
└─ generateDynamicMenus()
├─ 按顶层 route.layout 或默认分类归类
├─ 写入 quickReferenceMenus
├─ route.menu.hide ? 只保留索引 : 返回菜单项
├─ 递归转换 children
└─ 按 menu.order 排序排查清单
| 现象 | 优先检查 |
|---|---|
| 菜单为空 | 后端返回的节点是否都不在当前 routeTree 中。 |
| 详情页直接访问 403 | 后端是否返回 route pattern,例如 /manage/user/:id,适配后是 /manage/user/$id。 |
| 菜单点击 404 | 对应前端页面文件是否存在,routeTree.gen.ts 是否已更新。 |
| 菜单分类不对 | 后端顶层 layout 是否写成菜单分类 key,例如 admin。 |
| 左侧菜单不高亮 | handle.activeMenu 是否指向存在的可见菜单路径。 |
| roles 不生效 | 后端字段应是 handle.roles,适配后才会变成 permissions。 |
| extra 不显示 | handle.extra 是否是 setupAdminLayouts({ extras }) 已注册的 key。 |