布局系统

菜单与标签页

理解 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.menu、packages/web/admin-layouts/src/features/menus/menu-generator.tsx
切换静态菜单和动态菜单.env 的 VITE_AUTH_ROUTE_MODE、globalConfig.routeMode、setupAdminLayouts
让后端动态菜单进入后台布局loadDynamicRoutes、queryMenusOptions()、后端 BackendRoute.layout
解释或调整 menuCategoriesapps/admin/src/features/menus/menu-category.ts
增加分割线、固定入口或项目级菜单节点apps/admin/src/features/menus/menu-config.ts、menuNodeCallback
给菜单加业务扩展 UIapps/admin/src/features/menus/extras.ts、menu.extra
调整 tabs 缓存、固定 tab 或多 tabstaticData.tab、useAdminTab、storage.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.genMenuLayouts 从 menuCategoryKeys 派生,避免它和 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 route从 menuCategories 找到 /(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 会被归一成和静态菜单相同的 GeneratedMenu 与 quickReferenceMenus。关键点是:动态菜单只改变菜单、首页和授权数据来源,不会在运行时创建新的 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 时,可以按下面判断:

场景推荐
数字、红点、new、hot、简单状态文本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().logout()退出登录时先缓存当前 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 不出现menuNodeCallback 的 routeId 是否匹配 menuCategories.admin.layout,是否返回了 [] 之外的节点。
menu.extra 没渲染key 是否注册到 menuExtras,类型扩展是否来自 apps/admin/src/types/router.d.ts,setupAdminLayouts 是否传入 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当前 logout 会清 Query、认证存储、菜单,并缓存 tabs;用户切换时还会清 globalTabs。
在页面组件里手动维护 tabs 列表普通路由变化由 AdminTab 自动处理,页面只在明确需要时调用 tabs hook。

相关链接

内容位置
主布局如何组装布局系统
启动时为什么先调用 setupAdminLayouts启动流程
route staticData.menu、menu.badge、menu.extra路由元信息
静态和动态路由模式路由概览
权限和动态授权权限
共享布局包菜单与 tabs API菜单系统、状态与页签

Last updated on