路由元信息
理解 staticData 如何控制菜单、标题、图标、外链、隐藏路由、激活菜单和权限
这一页解释 apps/admin 里路由对象上的 staticData 应该怎么写。它不是装饰字段,而是菜单生成、面包屑、tabs、外链、iframe、权限和动态菜单协议共同使用的路由元信息。
适用场景
| 场景 | 重点字段 |
|---|---|
| 页面出现在左侧菜单 | staticData.title、staticData.i18nKey、staticData.menu |
| 调整菜单排序或图标 | menu.order、menu.icon、menu.localIcon |
| 详情页不显示在菜单,但保持父级菜单选中 | menu.hide、menu.activeMenu |
| 给页面加角色权限 | permissions |
| 打开外部站点 | href |
| 在后台内嵌 iframe 页面 | url、业务页面中的 IframePage |
| 切换 tabs 时保留页面实例 | keepAlive |
| 菜单上显示徽标或自定义扩展 | menu.badge、menu.extra |
| 把菜单做成分组标题或分隔线 | menu.type |
| 给菜单挂业务自定义元数据 | menu.meta |
| 从菜单打开页面时带默认查询参数 | query |
| 控制 tabs 行为 | tab.fixedIndex、tab.multi |
当前实现位置
| 文件 | 职责 |
|---|---|
packages/@core/types/src/app/router.d.ts | 定义全局 Router.Meta、Router.MenuBadge、Router.RouterContext。 |
apps/admin/src/features/router/index.ts | 将 TanStack Router 的 StaticDataRouteOption 扩展为 Router.Meta。 |
packages/web/admin-layouts/src/features/menus/menu-generator.tsx | 读取 staticData 或后端路由节点,生成菜单和 quick reference map。 |
packages/web/admin-layouts/src/features/menus/permissions.ts | 读取 permissions,结合用户角色和超级角色判断权限。 |
packages/web/admin-layouts/src/state/menus/use-admin-menus.tsx | 读取 menu.activeMenu、menu.hide,计算选中菜单、展开菜单和当前菜单信息。 |
apps/admin/src/pages/(admin)/** | 当前后台页面的 staticData 实例。 |
staticData 的职责
TanStack Router 本身负责路由匹配和组件渲染;Skyroc Admin 在 staticData 上扩展后台应用需要的元信息。
export const Route = createFileRoute('/(admin)/home/')({
component: Home,
staticData: {
title: 'home',
i18nKey: 'route.home',
menu: {
icon: 'mdi:monitor-dashboard',
order: 1
}
}
});这段配置会被多个地方消费:
| 消费方 | 使用方式 |
|---|---|
| 菜单生成器 | 读取 menu、title、i18nKey、href、url,生成后台菜单树。 |
| 权限守卫 | 静态模式读取 matched routes 的 permissions;动态模式读取后端路由节点的 permissions。 |
| 菜单选中态 | 读取 menu.hide 和 menu.activeMenu,处理详情页和隐藏页。 |
| 面包屑和搜索 | 读取 quick reference map 中的 title、i18nKey、icon、父级链路。 |
| tabs | 读取 tab、菜单标题、图标和当前路由信息生成标签页。 |
| 外链守卫 | 读取 matched route 的 href,打开新窗口后回退到首页或 404。 |
字段速查
顶层字段(Router.Meta,定义在 packages/@core/types/src/app/router.d.ts):
| 字段 | 类型 | 作用 | 说明 |
|---|---|---|---|
title | string | null | 路由默认标题 | 当没有 i18nKey 或翻译缺失时作为标题来源。 |
i18nKey | I18n.I18nKey | null | 国际化标题 key | 菜单、面包屑、tabs 会优先用它翻译标题,设置后忽略 title。 |
menu | object | 菜单配置 | 控制是否渲染菜单、排序、图标、徽标、扩展内容、类型和选中策略,见下方子字段。 |
permissions | string[] | null | 访问权限 | 和用户 roles 对比;超级角色由 VITE_STATIC_SUPER_ROLE 配置。 |
href | string | null | 外部链接 | 守卫会在非 preload 导航时 window.open,当前页面回退到首页或 404。 |
url | string | null | iframe 页面 URL | 只是元信息;需要页面组件自己读取并渲染 IframePage。 |
keepAlive | boolean | null | 页面实例缓存 | 为 true 时,布局内容区会在 tabs 切换时保留页面组件实例和 DOM 状态。 |
query | { key: string; value: string }[] | null | 菜单默认 search 参数 | 从菜单打开该路由时携带的默认 URL query,由布局转成 Record<string, string>。 |
requiresAuth | boolean | 认证意图 | 元信息类型已预留;当前后台认证边界主要由 (admin) layout 守卫控制。 |
tab | object | 标签页行为 | 可配置固定 tab(fixedIndex)和同一路由多 tab(multi)策略。 |
menu 字段
menu 决定路由如何进入后台菜单。普通菜单项通常只需要 icon 和 order:
staticData: {
title: 'manage_user',
i18nKey: 'route.manage_user',
menu: {
icon: 'ic:round-manage-accounts',
order: 1
},
permissions: ['R_ADMIN']
}子字段总表
| 子字段 | 类型 | 说明 |
|---|---|---|
icon | string | null | Iconify 图标名,如 mdi:monitor-dashboard。 |
localIcon | string | null | 本地 SVG 图标名(src/assets/svg-icon),优先级高于 icon。 |
order | number | null | 菜单排序,越小越靠前,缺省按 0。 |
hide | boolean | null | 为 true 时不渲染成菜单项,但路由仍存在。 |
activeMenu | RoutePath | null | 进入该路由时高亮的菜单路径,常用于详情页或隐藏页。 |
type | 'item' | 'group' | 'divider' | 菜单节点类型,默认 item。 |
badge | MenuBadge | null | 标准徽标(dot / 数字 / 文本 / 状态色)。 |
extra | Extra | null | 自定义菜单扩展组件 key,来自应用注册的 menuExtras。 |
meta | { key: string; value: string }[] | null | 菜单附加元数据键值对,供业务自定义读取。 |
排序
menu.order 越小越靠前。没有配置时按 0 处理。
当前项目还通过 features/menus/menu-config.ts 给 (admin) 顶层菜单追加 divider:
const adminTopLevelMenuNodes = [
{
id: 'admin-feature-divider',
menu: {
order: 6,
type: 'divider'
}
}
];因此,顶层菜单 order 不只是页面路由之间排序,也会和这些扩展节点一起排序。
图标
菜单支持两类图标:
| 字段 | 用法 |
|---|---|
menu.icon | Iconify 图标,例如 mdi:monitor-dashboard、carbon:user-role。 |
menu.localIcon | 本地图标名称,来自 apps/admin/src/assets/svg-icon,优先级高于 icon。 |
如果两者都没有配置,会使用 .env 中的默认图标:
VITE_MENU_ICON=mdi:menu本地图标前缀由 .env 控制:
VITE_ICON_LOCAL_PREFIX=icon-local隐藏菜单
menu.hide: true 表示路由存在,但不渲染成菜单项。
典型场景是详情页:
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'
},
permissions: ['R_ADMIN']
}
});这里有两个关键点:
| 字段 | 作用 |
|---|---|
hide: true | 不把详情页渲染到菜单树里。 |
activeMenu: '/manage/user' | 进入详情页时,让左侧菜单选中用户列表。 |
menu.hide 不等于路由不存在。静态菜单生成器仍会把隐藏路由写进 quick reference map,所以权限和选中态仍能找到它。
徽标
标准徽标用 menu.badge。首页当前使用了动态值:
const HOME_MENU_BADGE_KEY = 'home.updates';
export const Route = createFileRoute('/(admin)/home/')({
component: Home,
staticData: {
i18nKey: 'route.home',
title: 'home',
menu: {
icon: 'mdi:monitor-dashboard',
order: 1,
badge: {
type: 'normal',
valueKey: HOME_MENU_BADGE_KEY
}
}
}
});页面里通过 useAdminMenuBadges() 写入值:
const Home = () => {
const { setMenuBadgeValue } = useAdminMenuBadges();
useEffect(() => {
setMenuBadgeValue(HOME_MENU_BADGE_KEY, 25);
}, []);
return <HomeDashboard />;
};MenuBadge 完整字段:
| 字段 | 类型 | 说明 |
|---|---|---|
type | 'dot' | 'normal' | dot 只显示状态点;normal 显示内容徽标。 |
value | number | string | null | 静态徽标内容,无动态值时使用。 |
valueKey | string | 从布局徽标值表读取动态内容的 key,配合 useAdminMenuBadges()。 |
variant | 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info' | 徽标状态色。 |
showZero | boolean | 内容为 0 时是否仍渲染徽标。 |
badge.valueKey 适合动态数量;badge.value 适合固定文本;badge.type: 'dot' 适合只显示一个状态点。
自定义扩展
当标准徽标不够用时,可以使用 menu.extra。当前 apps/admin/src/features/menus/extras.ts 注册了:
export const menuExtras = {
ReleaseChannel: ReleaseChannelMenuExtra
};bootstrap.tsx 会把它传给布局系统:
setupAdminLayouts({
extras: menuExtras
});路由或后端菜单节点可以通过:
menu: {
extra: 'ReleaseChannel'
}引用这个扩展。apps/admin/src/types/router.d.ts 会把 MenuExtraRegistry 扩展成当前注册的 key,避免写错字符串。
菜单类型
menu.type 控制节点在菜单树里的角色,默认 item:
| 取值 | 含义 |
|---|---|
item | 普通可点击菜单项(默认值)。 |
group | 分组标题,用于把一组菜单归类展示,本身不跳转。 |
divider | 分隔线,用于在视觉上分隔菜单区块。 |
页面路由通常不需要写 type。divider、group 这类节点多由 features/menus/menu-config.ts 的扩展节点提供,例如前面 admin-feature-divider 的例子。
菜单元数据
menu.meta 是一组键值对,用于给菜单节点挂业务自定义数据:
menu: {
icon: 'mdi:flask-outline',
order: 5,
meta: [
{ key: 'group', value: 'beta' },
{ key: 'tracking', value: 'menu_lab' }
]
}它不影响菜单渲染或权限,仅作为附加信息透传,供业务侧读取(如埋点、分组标记)。类型是 { key: string; value: string }[] | null。
菜单默认查询参数(query)
query 让"从菜单点击进入页面"时自动带上默认的 URL search 参数。结构是键值对数组:
staticData: {
title: 'manage_user',
i18nKey: 'route.manage_user',
menu: {
icon: 'ic:round-manage-accounts',
order: 1
},
query: [
{ key: 'status', value: '1' },
{ key: 'tab', value: 'basic' }
]
}布局的 createRouteSearch() 会把它转换成 Record<string, string> 再拼到菜单跳转链接上,例如生成 /manage/user?status=1&tab=basic。
| 说明 | 当前行为 |
|---|---|
| 数据结构 | { key: string; value: string }[](即 Api.Route.BackendRouteQuery[])。 |
| 生效时机 | 仅作用于通过菜单点击进入页面时的默认 search,不会覆盖用户后续在页面里改写的查询参数。 |
| 空 key | key 为空的项会被过滤,不会写入 URL。 |
| 动态模式 | 后端路由节点同样支持 query,由菜单配置抽屉录入并随接口返回。 |
如果页面用 validateSearch 做了 search 校验(参考 表格与表单 的 URL 同步),query 提供的默认值需要与该 schema 兼容。
外链和 iframe
href:打开新窗口
href 表示这个路由菜单项点击后打开外部链接。当前 soybean-docs 是这个模式:
const SOYBEAN_DOCS_URL = 'https://docs.soybeanjs.cn/zh/guide/intro.html';
const SoybeanDocs = () => {
return null;
};
export const Route = createFileRoute('/(admin)/soybean-docs')({
component: SoybeanDocs,
staticData: {
href: SOYBEAN_DOCS_URL,
menu: {
icon: 'mdi:book-open-page-variant',
order: 30
},
title: 'Soybean Docs'
}
});外链不是在组件里打开,而是在 guardAdminRoute() 中统一处理。这样可以保证外链仍经过登录、权限和动态菜单授权。
守卫只在非 preload 导航时打开外链。因为 router 配置了 defaultPreload: 'intent',用户 hover 或预加载时不能提前弹出新窗口。
url:页面内 iframe
url 是给页面组件消费的元信息。当前 soybean-docs-iframe 读取 route match 的 staticData.url:
const ROUTE_PATH = '/(admin)/soybean-docs-iframe';
const SoybeanDocsIframe = () => {
const { staticData } = useMatch({ from: ROUTE_PATH });
return <IframePage title={staticData.title} url={staticData.url} />;
};再在路由上配置:
staticData: {
menu: {
icon: 'mdi:book-open-page-variant-outline',
order: 31
},
title: 'Soybean Docs Iframe',
url: 'https://docs.soybeanjs.cn/zh/guide/intro.html'
}因此,url 不会自动渲染 iframe;它只是让页面和菜单共享同一份配置。
权限字段
permissions 是角色权限数组:
staticData: {
title: 'role',
i18nKey: 'route.manage_role',
menu: {
icon: 'carbon:user-role',
order: 2
},
permissions: ['R_SUPER']
}权限判断规则在 packages/web/admin-layouts/src/features/menus/permissions.ts:
| 条件 | 结果 |
|---|---|
没有配置 permissions | 允许访问。 |
用户 roles 包含 VITE_STATIC_SUPER_ROLE | 允许访问。 |
用户 roles 命中任意一个 permissions | 允许访问。 |
| 以上都不满足 | 无权限。 |
当前 .env 中超级角色是:
VITE_STATIC_SUPER_ROLE=R_SUPER静态模式下,权限来自前端 staticData.permissions。动态模式下,直接 URL 授权来自后端路由节点上的 permissions;后端返回的节点类型同样继承 Router.Meta。
静态元信息和动态菜单的关系
staticData 在静态模式下是菜单和权限的主数据源;动态模式下,菜单主数据源变成后端接口。
| 模式 | 菜单标题、图标、排序来源 | 直接 URL 授权来源 |
|---|---|---|
static | 前端 staticData | route match 的 staticData.permissions |
dynamic | 后端 BackendRoute 节点 | 后端 route quick reference map 和节点 permissions |
但是动态模式仍然需要前端 route tree。后端返回的 path 必须能对应一个前端真实路由,例如 /manage/user、/manage/user/$id。如果后端只返回了菜单节点但前端没有页面文件,菜单可以出现,点击后仍然会进入前端 404。
最小配置模板
普通菜单页
export const Route = createFileRoute('/(admin)/system/logs')({
component: SystemLogs,
staticData: {
title: 'system_logs',
i18nKey: 'route.system_logs',
menu: {
icon: 'mdi:file-document-outline',
order: 20
},
permissions: ['R_ADMIN']
}
});隐藏详情页
export const Route = createFileRoute('/(admin)/system/logs/$id')({
component: SystemLogDetail,
staticData: {
title: 'system_log_detail',
i18nKey: 'route.system_log_detail',
menu: {
hide: true,
activeMenu: '/system/logs'
},
permissions: ['R_ADMIN']
}
});外链菜单
export const Route = createFileRoute('/(admin)/external-docs')({
component: ExternalDocs,
staticData: {
href: 'https://example.com/docs',
title: 'External Docs',
menu: {
icon: 'mdi:open-in-new',
order: 90
}
}
});类型参考
Router.Meta 与相关类型完整定义如下(packages/@core/types/src/app/router.d.ts):
interface Meta {
title?: string | null;
i18nKey?: I18n.I18nKey | null;
keepAlive?: boolean | null;
href?: string | null;
url?: string | null;
permissions?: string[] | null;
query?: Api.Route.BackendRouteQuery[] | null; // { key: string; value: string }[]
requiresAuth?: boolean;
menu?: {
icon?: string | null;
localIcon?: string | null;
order?: number | null;
hide?: boolean | null;
activeMenu?: RoutePath | null;
type?: MenuType | null; // 'item' | 'group' | 'divider',默认 'item'
badge?: MenuBadge | null;
extra?: Extra | null;
meta?: { key: string; value: string }[] | null;
};
tab?: {
fixedIndex?: number | null;
multi?: boolean | null;
};
}
interface MenuBadge {
type?: 'dot' | 'normal';
value?: number | string | null;
valueKey?: string;
variant?: 'default' | 'error' | 'info' | 'primary' | 'success' | 'warning';
showZero?: boolean;
}
type MenuType = 'divider' | 'group' | 'item';
type MenuBadgeType = 'dot' | 'normal';
type MenuBadgeValue = number | string | null;
type MenuBadgeVariant = 'default' | 'error' | 'info' | 'primary' | 'success' | 'warning';注意 Extra 与 RoutePath 是注册型字符串字面量:Extra 来自应用注册的 MenuExtraRegistry,RoutePath 来自生成的 RoutePathRegistry,因此 extra 和 activeMenu 在编译期能获得字符串提示与校验。
常见误区
| 误区 | 正确理解 |
|---|---|
title 和 i18nKey 随便写一个即可 | 有 i18nKey 时会优先翻译;title 仍应作为 fallback 和搜索/调试语义。 |
hide: true 会让路由不能访问 | 它只隐藏菜单,不删除路由,也不绕过权限守卫。 |
activeMenu 可以写路由组路径 | 应写最终可跳转的规范化路径,例如 /manage/user,不是 /(admin)/manage/user/。 |
href 应该在组件里 window.open | 当前外链由 guardAdminRoute() 统一打开,组件通常返回 null。 |
动态模式下可以不维护前端 staticData | 动态菜单可以不依赖它,但前端页面仍需要清晰的 route、标题和 fallback 信息,静态模式与本地开发也会使用。 |
permissions 是按钮权限 | 这里是路由访问权限;按钮级或接口级权限应在业务模块里单独设计。 |
query 会覆盖用户当前的查询条件 | 它只是菜单点击进入时的默认 search;用户在页面里改写后以页面状态为准。 |
menu.meta 会影响菜单渲染或权限 | 它只是透传的业务自定义键值对,不参与渲染、排序或鉴权。 |
用 menu.type: 'group'/'divider' 承载真实页面 | 分组和分隔线是结构性节点,本身不跳转;可点击页面应是 item。 |