Skyroc Admin React
布局系统

菜单与标签页

理解 apps/admin 如何通过 setupAdminLayouts、menuCategories、menuNodeCallback、menuExtras 和 cache tabs 接入菜单与标签页

这一页解释 apps/admin 的菜单和标签页如何从路由、动态菜单接口和布局配置中生成。重点是分清几个入口的职责:setupAdminLayouts 注入运行时配置,features/menus 定义应用侧扩展,认证初始化生成菜单,WebAdminLayout 消费菜单并维护 tabs。

当前主链路是:

bootstrap.tsx
  -> setupAdminLayouts({ routeTree, routeMode, menuCategories, menuNodeCallback, extras, storage })

useAuth().initAuth()
  -> initMenus(userInfo)
    -> menuGenerator.generate(...)
      -> allMenus
      -> quickReferenceMenus
      -> home

WebAdminLayout
  -> useAdminMenus()
  -> AdminMenu / AdminSider / AdminHeader
  -> useAdminTab()
  -> AdminTab
  -> cacheTabs()

适用场景

场景重点位置
修改静态菜单来源或默认排序路由 staticData.menupackages/web/admin-layouts/src/features/menus/menu-generator.tsx
切换静态菜单和动态菜单.envVITE_AUTH_ROUTE_MODEglobalConfig.routeModesetupAdminLayouts
让后端动态菜单进入后台布局loadDynamicRoutesqueryMenusOptions()、后端 BackendRoute.layout
解释或调整 menuCategoriesapps/admin/src/features/menus/menu-category.ts
增加分割线、固定入口或项目级菜单节点apps/admin/src/features/menus/menu-config.tsmenuNodeCallback
给菜单加业务扩展 UIapps/admin/src/features/menus/extras.tsmenu.extra
调整 tabs 缓存、固定 tab 或多 tabstaticData.tabuseAdminTabstorage.globalTabs

当前实现位置

文件职责
apps/admin/src/bootstrap.tsx调用 setupAdminLayouts,注入 route tree、菜单模式、动态菜单加载器、分类、扩展节点、extra 组件和缓存适配器。
apps/admin/src/features/menus/menu-category.ts定义当前应用的菜单分类。当前只有 admin -> /(admin)
apps/admin/src/features/menus/menu-config.ts通过 menuNodeCallback 为后台顶层菜单追加 divider。
apps/admin/src/features/menus/extras.ts注册 menu.extra 可引用的业务组件。当前有 ReleaseChannel
apps/admin/src/types/router.d.ts扩展 Router.MenuExtraRegistry,让 menu.extra 使用应用侧注册 key。
apps/admin/src/features/auth/use-auth.ts登录后调用 initMenus(data) 生成菜单;退出时清理菜单并缓存 tabs。
apps/admin/src/features/auth/use-login.ts用户切换时清理 globalTabs,避免把上一个用户的 tabs 带给新用户。
packages/web/admin-layouts/src/features/menus/*菜单生成、权限过滤、渲染、quick reference 和动态 badge。
packages/web/admin-layouts/src/state/tabs/*tabs 初始化、添加、关闭、固定、切换和缓存。

核心概念

setupAdminLayouts 是布局运行时配置入口

bootstrap.tsx 在 React 渲染前调用 setupAdminLayouts

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
});

这些配置不会直接渲染 UI。它们被保存为布局运行时选项,后续由菜单生成器、权限判断、tabs 状态和布局组件读取。

配置作用
defaultHome静态模式默认首页;动态模式没有返回首页时也会回退到它。
defaultIcon路由或后端菜单没有配置 icon 时的兜底图标。
routeMode决定菜单由前端 route tree 生成,还是由 loadDynamicRoutes 返回的后端路由树生成。
routeTreeTanStack Router 生成的 route tree,静态菜单和路由边界都依赖它。
loadDynamicRoutes动态模式加载后端菜单和首页。当前通过 React Query 的 queryMenusOptions() 复用接口缓存。
menuCategories描述菜单分类和 layout route 的对应关系。
menuNodeCallback给指定 route 节点追加非路由声明的菜单节点。
extras注册 menu.extra 字符串到 React 组件的映射。
permissionSuperRole静态、动态权限校验中的超级角色标识。
storage读写 globalTabs、移动端布局备份、mix 菜单固定等布局缓存。

当前应用只有一个后台壳:

export const menuCategories = {
  admin: {
    key: 'admin',
    layout: '/(admin)'
  }
} as const satisfies Record<string, AdminLayoutMenuCategory>;

它不是业务菜单数据,也不是一级菜单分组。它只回答一个问题:哪一个 layout route 下面的菜单属于哪一个菜单分类。

模式menuCategories 的作用
静态模式通过 layout: '/(admin)' 找到 TanStack Router 的后台 layout route,只扫描它下面的子路由生成 admin 菜单。
动态模式后端顶层 BackendRoute.layout 可以写分类 key,例如 admin,布局包据此把菜单归入对应分类。
布局渲染WebAdminLayout 默认读取第一个分类;多后台壳时可传 categoryKey 读取指定分类。

globalConfig.genMenuLayoutsmenuCategoryKeys 派生,避免它和 setupAdminLayouts({ menuCategories }) 手写两份后发生漂移。

静态菜单来自 route staticData

静态模式下,菜单从 routeTree 中的 staticData 生成:

export const Route = createFileRoute('/(admin)/manage/user/')({
  component: UserList,
  staticData: {
    title: 'manage_user',
    i18nKey: 'route.manage_user',
    menu: {
      icon: 'ic:round-manage-accounts',
      order: 1
    },
    permissions: ['R_ADMIN']
  }
});

生成器会做几件事:

步骤行为
找 layout routemenuCategories 找到 /(admin),扫描它的子路由。
读取 staticData没有 staticData 或无权限的 route 不进入菜单。
写 quick reference隐藏路由也会写入 quickReferenceMenus,用于权限、选中态和 tabs。
处理 menu.hide隐藏菜单不渲染菜单项,但路由仍然可被索引。
排序和补充节点menu.order 排序,并追加 menuNodeCallback 返回的节点。

动态菜单来自后端路由树

动态模式由 .env 中的 VITE_AUTH_ROUTE_MODE=dynamic 控制。此时 initMenus() 会调用 loadDynamicRoutes,当前实现是:

loadDynamicRoutes: loadAdminDynamicRoutes

loadAdminDynamicRoutes() 会先通过 queryMenusOptions() 读取真实后端菜单响应,再适配成布局包需要的动态菜单结构。动态菜单返回的 routes 会被归一成和静态菜单相同的 GeneratedMenuquickReferenceMenus。关键点是:动态菜单只改变菜单、首页和授权数据来源,不会在运行时创建新的 React 页面组件。

因此后端返回的 path 仍然要对应前端已经存在的 TanStack Router 页面。适配层会过滤当前 routeTree 不存在的路径,避免菜单指向前端没有实现的页面。

当前应用用 menuNodeCallback 给后台顶层菜单插入分割线:

import type { MenuNodeCallback, MenuNodeConfig } from '@skyroc/web-admin-layouts';

import { menuCategories } from './menu-category';

const adminTopLevelMenuNodes = [
  {
    id: 'admin-feature-divider',
    menu: {
      order: 6,
      type: 'divider'
    }
  },
  {
    id: 'admin-about-divider',
    menu: {
      order: 20,
      type: 'divider'
    }
  }
] satisfies MenuNodeConfig[];

export const menuNodeCallback: MenuNodeCallback = routeId => {
  if (routeId !== menuCategories.admin.layout) return [];

  return adminTopLevelMenuNodes;
};

它适合补充不属于普通页面路由的节点:

适合不适合
divider、固定入口、项目级聚合菜单普通页面菜单
静态和动态模式都需要的补充节点某个页面自己的 title、icon、permissions
需要按 route 节点追加的手写菜单后端已经能返回的普通业务菜单

没有节点时返回 []。不要用 undefined 表示 no-op,这样调用方和读者都更清楚。

标准菜单徽标用 menu.badge;完全自定义的菜单右侧 UI 用 menu.extra。当前应用注册了 ReleaseChannel

import ReleaseChannelMenuExtra from './components/ReleaseChannelMenuExtra';

export const menuExtras = {
  ReleaseChannel: ReleaseChannelMenuExtra
};

export type ExtraKey = keyof typeof menuExtras;

apps/admin/src/types/router.d.ts 再把这个 key 注册给全局类型:

declare namespace Router {
  interface MenuExtraRegistry extends Record<import('@/features/menus/extras').ExtraKey, true> {}
}

这样 route 或后端菜单写 menu.extra 时可以获得类型约束:

staticData: {
  title: 'about',
  menu: {
    extra: 'ReleaseChannel',
    icon: 'fluent:book-information-24-regular',
    order: 22
  }
}

选择标准 badge 还是 extra 时,可以按下面判断:

场景推荐
数字、红点、newhot、简单状态文本menu.badge
后端动态菜单也需要复用的通用状态menu.badge
需要订阅统一动态值表badge.valueKey + useAdminMenuBadges
需要复杂样式、组合内容或组件内部逻辑menu.extra

Tabs 来源于 quick reference menus

AdminTab 不直接扫描 route tree。它通过 useAdminTab() 读取 useAdminMenus() 提供的当前路由和 quickReferenceMenus,再把当前 route 转成 tab。

quickReferenceMenus[path]
  -> title / i18nKey / icon / localIcon / tab
  -> getTabByMenuInfo(...)
  -> App.Global.Tab

staticData.tab 可以影响 tab 行为:

字段作用
tab.fixedIndex固定 tab 的排序位置。首页在初始化时默认固定为 0
tab.multi同一路由是否允许按完整 URL 生成多个 tab。适合详情页或带查询参数的页面。

普通页面不需要手动添加 tab。路由变化后,AdminTab 会调用 addTab(route.fullPath, route.originPath)

Tabs 缓存写入 globalTabs

tabs 缓存由主题设置中的 tab.cache 控制。启用时,initTabStore() 会读取 storage.globalTabs,过滤掉已经不存在的路由,再恢复 tabs。

缓存写入发生在两个位置:

位置行为
AdminTabEffect页面卸载前调用 cacheTabs(),写入 storage.globalTabs
useAuth().clearAuth()退出登录时先缓存当前 tabs,再清理菜单和认证状态。

登录用户切换时,use-login.ts 会比较 lastLoginUserId。如果不是同一个用户,会删除 globalTabs,避免新用户恢复上一个用户的标签页。

最小可用示例

增加一个后台顶层分割线

如果只是给后台顶层菜单增加分割线,改 apps/admin/src/features/menus/menu-config.ts

const adminTopLevelMenuNodes = [
  {
    id: 'admin-feature-divider',
    menu: {
      order: 6,
      type: 'divider'
    }
  },
  {
    id: 'admin-report-divider',
    menu: {
      order: 12,
      type: 'divider'
    }
  },
  {
    id: 'admin-about-divider',
    menu: {
      order: 20,
      type: 'divider'
    }
  }
] satisfies MenuNodeConfig[];

分割线和普通菜单一起按 order 排序。它不需要 path,也不会进入路由跳转。

注册一个新的菜单 extra

先新增业务组件,再注册到 menuExtras

import { Tag } from 'antd';

interface BetaMenuExtraProps {
  /** 菜单标题,用于辅助生成可读文案。 */
  title?: string;
}

const BetaMenuExtra = (props: BetaMenuExtraProps) => {
  const { title = 'menu' } = props;

  return <Tag aria-label={`${title} beta`}>Beta</Tag>;
};

export default BetaMenuExtra;
import BetaMenuExtra from './components/BetaMenuExtra';
import ReleaseChannelMenuExtra from './components/ReleaseChannelMenuExtra';

export const menuExtras = {
  Beta: BetaMenuExtra,
  ReleaseChannel: ReleaseChannelMenuExtra
};

之后路由可使用:

staticData: {
  title: 'report_center',
  i18nKey: 'route.report_center',
  menu: {
    extra: 'Beta',
    icon: 'mdi:file-chart-outline',
    order: 12
  }
}

配置详情页多 tab

详情页如果希望不同参数打开不同标签,可以配置 tab.multi

export const Route = createFileRoute('/(admin)/manage/user/$id')({
  component: UserDetail,
  staticData: {
    title: 'manage_user_detail',
    i18nKey: 'route.manage_user_$id',
    menu: {
      hide: true,
      activeMenu: '/manage/user'
    },
    tab: {
      multi: true
    }
  }
});

multi: true 时 tab id 使用完整 URL;不开启时同一路由 path 只保留一个 tab。

排查顺序

现象先检查
菜单完全为空setupAdminLayouts 是否早于 App 渲染,menuCategories 是否包含当前 layout,登录后 initMenus() 是否执行。
静态菜单缺某个页面页面是否在 (admin) 下,是否有 staticData,当前用户是否满足 permissions
动态菜单出现但点击 404后端 path 是否对应前端真实 TanStack Route。动态菜单不会创建页面组件。
divider 不出现menuNodeCallbackrouteId 是否匹配 menuCategories.admin.layout,是否返回了 [] 之外的节点。
menu.extra 没渲染key 是否注册到 menuExtras,类型扩展是否来自 apps/admin/src/types/router.d.tssetupAdminLayouts 是否传入 extras
tabs 不恢复主题设置中的 tab.cache 是否开启,storage.globalTabs 是否被用户切换逻辑清理,缓存中的路由是否仍存在。
详情页菜单不选中父级隐藏详情页是否配置了 menu.activeMenu

常见误区

误区正确做法
menuCategories 当成一级菜单配置它是 layout route 到菜单分类的映射,不是业务菜单树。
menuNodeCallback 写普通页面菜单普通页面菜单写在 route staticData.menu 或后端 BackendRoute.menu
动态菜单模式下认为后端能下发任意 React 页面后端只能下发菜单和路由元信息,页面组件仍来自前端 route tree。
标准数字徽标也写成 menu.extra数字、红点和文本状态优先用 menu.badge,复杂业务 UI 才用 menu.extra
退出登录时只清 token,不处理菜单和 tabs当前 clearAuth 会清 Query、认证存储、菜单,并缓存 tabs;用户切换时还会清 globalTabs
在页面组件里手动维护 tabs 列表普通路由变化由 AdminTab 自动处理,页面只在明确需要时调用 tabs hook。

相关链接

内容位置
主布局如何组装布局系统
启动时为什么先调用 setupAdminLayouts启动流程
route staticData.menumenu.badgemenu.extra路由元信息
静态和动态路由模式路由概览
权限和动态授权权限
共享布局包菜单与 tabs APIdocs/web-kit-docs/content/docs/admin-layouts/*

On this page