Skyroc Admin React
路由

路由元信息

理解 staticData 如何控制菜单、标题、图标、外链、隐藏路由、激活菜单和权限

这一页解释 apps/admin 里路由对象上的 staticData 应该怎么写。它不是装饰字段,而是菜单生成、面包屑、tabs、外链、iframe、权限和动态菜单协议共同使用的路由元信息。

适用场景

场景重点字段
页面出现在左侧菜单staticData.titlestaticData.i18nKeystaticData.menu
调整菜单排序或图标menu.ordermenu.iconmenu.localIcon
详情页不显示在菜单,但保持父级菜单选中menu.hidemenu.activeMenu
给页面加角色权限permissions
打开外部站点href
在后台内嵌 iframe 页面url、业务页面中的 IframePage
切换 tabs 时保留页面实例keepAlive
菜单上显示徽标或自定义扩展menu.badgemenu.extra
把菜单做成分组标题或分隔线menu.type
给菜单挂业务自定义元数据menu.meta
从菜单打开页面时带默认查询参数query
控制 tabs 行为tab.fixedIndextab.multi

当前实现位置

文件职责
packages/@core/types/src/app/router.d.ts定义全局 Router.MetaRouter.MenuBadgeRouter.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.activeMenumenu.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
    }
  }
});

这段配置会被多个地方消费:

消费方使用方式
菜单生成器读取 menutitlei18nKeyhrefurl,生成后台菜单树。
权限守卫静态模式读取 matched routes 的 permissions;动态模式读取后端路由节点的 permissions
菜单选中态读取 menu.hidemenu.activeMenu,处理详情页和隐藏页。
面包屑和搜索读取 quick reference map 中的 titlei18nKeyicon、父级链路。
tabs读取 tab、菜单标题、图标和当前路由信息生成标签页。
外链守卫读取 matched route 的 href,打开新窗口后回退到首页或 404。

字段速查

顶层字段(Router.Meta,定义在 packages/@core/types/src/app/router.d.ts):

字段类型作用说明
titlestring | null路由默认标题当没有 i18nKey 或翻译缺失时作为标题来源。
i18nKeyI18n.I18nKey | null国际化标题 key菜单、面包屑、tabs 会优先用它翻译标题,设置后忽略 title
menuobject菜单配置控制是否渲染菜单、排序、图标、徽标、扩展内容、类型和选中策略,见下方子字段。
permissionsstring[] | null访问权限和用户 roles 对比;超级角色由 VITE_STATIC_SUPER_ROLE 配置。
hrefstring | null外部链接守卫会在非 preload 导航时 window.open,当前页面回退到首页或 404。
urlstring | nulliframe 页面 URL只是元信息;需要页面组件自己读取并渲染 IframePage
keepAliveboolean | null页面实例缓存为 true 时,布局内容区会在 tabs 切换时保留页面组件实例和 DOM 状态。
query{ key: string; value: string }[] | null菜单默认 search 参数从菜单打开该路由时携带的默认 URL query,由布局转成 Record<string, string>
requiresAuthboolean认证意图元信息类型已预留;当前后台认证边界主要由 (admin) layout 守卫控制。
tabobject标签页行为可配置固定 tab(fixedIndex)和同一路由多 tab(multi)策略。

menu 决定路由如何进入后台菜单。普通菜单项通常只需要 iconorder

staticData: {
  title: 'manage_user',
  i18nKey: 'route.manage_user',
  menu: {
    icon: 'ic:round-manage-accounts',
    order: 1
  },
  permissions: ['R_ADMIN']
}

子字段总表

子字段类型说明
iconstring | nullIconify 图标名,如 mdi:monitor-dashboard
localIconstring | null本地 SVG 图标名(src/assets/svg-icon),优先级高于 icon
ordernumber | null菜单排序,越小越靠前,缺省按 0
hideboolean | nulltrue 时不渲染成菜单项,但路由仍存在。
activeMenuRoutePath | null进入该路由时高亮的菜单路径,常用于详情页或隐藏页。
type'item' | 'group' | 'divider'菜单节点类型,默认 item
badgeMenuBadge | null标准徽标(dot / 数字 / 文本 / 状态色)。
extraExtra | 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.iconIconify 图标,例如 mdi:monitor-dashboardcarbon: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 显示内容徽标。
valuenumber | string | null静态徽标内容,无动态值时使用。
valueKeystring从布局徽标值表读取动态内容的 key,配合 useAdminMenuBadges()
variant'default' | 'primary' | 'success' | 'warning' | 'error' | 'info'徽标状态色。
showZeroboolean内容为 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分隔线,用于在视觉上分隔菜单区块。

页面路由通常不需要写 typedividergroup 这类节点多由 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,不会覆盖用户后续在页面里改写的查询参数。
空 keykey 为空的项会被过滤,不会写入 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前端 staticDataroute 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';

注意 ExtraRoutePath 是注册型字符串字面量:Extra 来自应用注册的 MenuExtraRegistryRoutePath 来自生成的 RoutePathRegistry,因此 extraactiveMenu 在编译期能获得字符串提示与校验。

常见误区

误区正确理解
titlei18nKey 随便写一个即可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

相关位置

主题继续查看
页面文件如何生成路由路由概览
页面切换时保留表单和 DOM 状态路由缓存
登录拦截和守卫链路路由守卫
权限判定和 403权限
菜单分类、扩展节点和 tabs菜单与标签页
国际化 key 和图标资源国际化与图标

On this page