Skyroc Web Kit
Admin Layouts

动态路由菜单生成

从真实后端菜单响应适配到 Admin Layouts 动态菜单运行时

动态模式不是把后端 JSON 直接塞给布局包。真实后端返回的是业务菜单响应,当前项目再把它适配成 AdminLayoutsDynamicRoutes,最后由 @skyroc/web-admin-layouts 生成菜单树、快速索引和首页。

当前边界是:

层级数据形状职责
后端接口BackendRouteResponse返回用户首页和授权菜单树,字段接近 name/path/component/handle
apps/admin 适配层AdminLayoutsDynamicRoutes规范化路径、过滤不存在的前端页面、把 handle 映射成布局 meta。
@skyroc/web-admin-layoutsApi.Route.BackendRoute[]生成 GeneratedMenuquickReferenceMenus 和动态首页。
TanStack RouterrouteTree提供真实 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"
              }
            }
          ]
        }
      ]
    }
  ]
}

这不是布局包最终消费的结构。componentredirectnamehandle 都是接口层字段,需要应用侧转换。

适配后结构

布局包消费的是:

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 的适配规则:

后端字段适配后字段
homehome,但只保留当前 routeTree 中存在的路径。
pathpath,会去掉尾部 /,并把 :id 转成 TanStack Router 的 $id
name / idid,优先后端 id,否则使用 name,最后回退到 path
layoutlayout,如果后端返回,按菜单分类 key 处理,例如 admin
handle.titletitle
handle.i18nKeyi18nKey
handle.rolespermissions
handle.hrefhref
handle.urlurl
handle.keepAlivekeepAlive
handle.fixedIndexInTabtab.fixedIndex
handle.multiTabtab.multi
handle.iconmenu.icon
handle.localIconmenu.localIcon
handle.ordermenu.order
handle.hideInMenumenu.hide
handle.activeMenumenu.activeMenu,同样要能匹配当前 routeTree
handle.badgemenu.badge
handle.extramenu.extra,必须是已注册的 extra key。

后端 component 不会被布局包直接渲染。当前项目的 React 页面仍然来自前端 apps/admin/src/pages 和生成的 routeTree

路径规则

动态菜单能不能工作,关键看 path 是否能匹配前端 route。

后端可能返回适配后前端需要存在
/home/homeapps/admin/src/pages/(admin)/home/index.tsx
/manage/user/manage/userapps/admin/src/pages/(admin)/manage/user/index.tsx
/manage/user/:id/manage/user/$idapps/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。

On this page