React 管理后台实战 · 多角色后台的菜单和路由怎么按角色收口?配置表驱动,新增权限不碰业务代码
各位看官,做管理后台最容易被低估、又最容易烂尾的一件事,就是多角色的菜单显隐和路由权限。
我这个项目里后台一共有五种角色:平台超管、平台渠道、租户管理员、租户经理、租户员工。刚接手的时候,菜单显隐和落地页散落在十几个组件里各自 if (role === 'xxx'),每加一个角色就要满世界找判断分支。后来我把它彻底重构成配置表驱动,新增一个角色只改一个地方。今天把这套方案一次说清,顺便聊几个真在线上踩过的坑。

先把角色模型讲清楚
后台所有角色共用同一套登录入口,登录后后端在 user 里塞一个 role 字段和一个 tenantId:
ts
// 角色类型(与 JWT 里的 role 字符串一致)
export type Role =
| 'platform_super_admin' // 平台超管
| 'platform_channel' // 平台渠道
| 'tenant_admin' // 租户管理员
| 'tenant_manager' // 租户经理
| 'tenant_employee' // 租户员工
export interface User {
id: string
tenantId: string | null // 关键:平台级角色(超管/渠道)这里为 null
name: string
email: string
role: Role
mustResetPassword?: number // 1 表示需强制改密
}
这里有个最容易忽略的坑 :平台超管和平台渠道的 tenantId 是 null,它们没有"当前租户"这个概念。渠道登录后看到的顶层页面不是某个租户的工作台,而是"我创建的租户列表",点进去才看单个租户。如果你在代码里默认假设"登录就有当前租户",渠道账号一进来就崩。这个坑我后面专门讲。
配置表驱动:三个核心表

与其在每个组件里写 if/else,不如把"角色 ↔ 权限"的映射全部收敛成配置表。
表一:角色 → 默认首页。
ts
// 新增角色只在此登记一项,落地页/重定向统一查这张表
export const ROLE_HOME_MAP: Record<Role, string> = {
platform_super_admin: '/platform/dashboard',
platform_channel: '/channel/dashboard',
tenant_admin: '/dashboard',
tenant_manager: '/dashboard',
tenant_employee: '/dashboard',
}
表二:菜单分组 + 每个菜单项声明可见角色。
ts
export interface MenuItem {
label: string
icon: LucideIcon
path: string
roles: Role[] // 哪些角色能看到这一项
}
export const MENU_GROUPS: MenuGroup[] = [
{ label: '业务', items: [
{ label: '业务数据管理', path: '/biz-data', roles: ['tenant_admin', 'tenant_manager'] },
{ label: '业务列表', path: '/biz-list', roles: ['tenant_admin', 'tenant_manager'] },
]},
{ label: '平台治理', items: [
{ label: '租户列表', path: '/platform/tenants', roles: ['platform_super_admin'] },
{ label: '渠道管理', path: '/platform/channels', roles: ['platform_super_admin'] },
]},
{ label: '渠道工作台', items: [
{ label: '我的租户', path: '/channel/tenants', roles: ['platform_channel'] },
]},
]
// 按当前角色过滤出可见菜单
export const visibleMenu = (role: Role): MenuGroup[] =>
MENU_GROUPS
.map((g) => ({ label: g.label, items: g.items.filter((i) => i.roles.includes(role)) }))
.filter((g) => g.items.length > 0)
表三:业务内的细粒度判定助手(用在按钮级,而不是菜单级)。
ts
// 是否可访问"数据导出中心":仅平台超管 / 租户管理员
export const canExportData = (role?: Role | null): boolean =>
role === 'platform_super_admin' || role === 'tenant_admin'
// 是否平台超管
export const isPlatformAdmin = (role?: Role | null): boolean =>
role === 'platform_super_admin'
这三张表就是整个权限体系的"单一事实来源"。侧边栏渲染直接调 visibleMenu(role),落地页直接查 ROLE_HOME_MAP,按钮显隐调 canExportData。

路由守卫三件套
路由层用三个守卫组件收口,避免权限判断散落:
tsx
// 已登录守卫:未登录跳登录,并携带回跳地址
export function RequireAuth({ children }: { children: ReactNode }) {
const { token, user } = useAuthStore()
const location = useLocation()
if (!token || !user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}
return <>{children}</>
}
// 角色守卫:不在允许角色内 → 跳 403
export function RequireRole({ roles, children }: { roles: Role[]; children: ReactNode }) {
const user = useAuthStore((s) => s.user)
if (!user || !roles.includes(user.role)) {
return <Navigate to="/403" replace />
}
return <>{children}</>
}
// 默认首页:统一查 ROLE_HOME_MAP,不在此硬编码分支
export function DefaultHome() {
const user = useAuthStore((s) => s.user)
const target = (user?.role && ROLE_HOME_MAP[user.role]) || '/dashboard'
return <Navigate to={target} replace />
}
注意 RequireAuth 那个 state={``{ from: location.pathname }}------登录成功后跳回原来想去的页面,别每次都踢回首页,这个细节很影响体验。
几个线上真踩过的坑
坑一:菜单显隐硬编码,加角色改 N 处。
最早菜单显隐是 if (role === 'tenant_admin' || role === 'tenant_manager') 写在组件里,后来加渠道角色,光是找这些分支就花了半天,还漏了两个。visibleMenu 收敛到配置表后,加角色只改 MENU_GROUPS 和 ROLE_HOME_MAP 两处,业务组件一行都不动。
坑二:403 和 404 混为一谈,泄露资源存在性。
路由守卫对"无权限访问某页面"返回 403,这没问题。但数据级越权 是另一回事:比如渠道账号查"别人的租户",后端为了不告诉它"这个租户存在但不属于你",统一返回 404(资源不存在)而不是 403。前端如果在这里弹"您无权限",就等于变相告诉对方"这东西是有的,只是你不配看",这在多租户系统里是实打实的信息泄露。正确做法是:凡是非 403 的业务查询失败(特别是 404),统一按"资源不存在"处理,文案不要带"无权限"字眼。后端怎么隔离,前端就要怎么配合------这是和后端越权防护对齐的一环(详见后端那篇)。
坑三:平台/渠道角色 tenantId 为 null。
前面提过。任何"取当前租户信息"的代码,必须先在渠道/平台视角下做空值兜底,否则 tenantId.toUpperCase() 一句就白屏。渠道工作台的首页就必须是"我的租户列表"这种"先选租户再进上下文"的结构。
坑四:有些角色根本不该进后台。
我们有一类角色是只给移动端 App 用的业务角色,逻辑上不该出现在管理后台。这类角色在登录成功、但进入后台路由前就要拦截掉,而不是等它点菜单才发现没权限。用一个 ADMIN_BLOCKED_ROLES 黑名单在落地前拦掉,比事后在每个页面守卫里堵要干净得多。
硬编码 vs 配置表,对比一下
| 维度 | 硬编码 if/else |
配置表驱动 |
|---|---|---|
| 新增角色改动点 | 10+ 处组件 | 1~2 处配置表 |
| 漏改风险 | 高(白屏/越权) | 低(统一查表) |
| 菜单显隐维护 | 各组件自管 | visibleMenu(role) 一处 |
| 落地页 | 散落 if(role===) |
查 ROLE_HOME_MAP |
| 按钮级权限 | 重复判断 | canExportData(role) 复用 |
五角色可见性一览
| 角色 | 默认首页 | 可见业务范围 | 平台/渠道专属 |
|---|---|---|---|
| 平台超管 | 平台仪表盘 | 平台治理全开 | 租户/渠道/全局统计 |
| 平台渠道 | 渠道仪表盘 | 仅自己创建的租户 | 我的租户/创建租户 |
| 租户管理员 | 租户仪表盘 | 业务 + 员工/分类/系统设置 | --- |
| 租户经理 | 租户仪表盘 | 业务(受限编辑) | --- |
| 租户员工 | 租户仪表盘 | 仅本人数据(受限) | --- |
小结
多角色后台的权限,核心就一句话:角色 ↔ 权限的映射全部收敛成配置表,业务代码只查表不判断 。菜单用 roles 数组过滤、落地页查 ROLE_HOME_MAP、按钮级用 canExportData 这类助手,三件套下来加角色几乎零成本。再记住后端做 404 隔离时前端别用"无权限"文案拆台、平台/渠道角色的 tenantId 必须为 null 兜底------这几点都是我在线上真踩过才记住的。
相关阅读
- React 管理后台实战 · 前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清
- React 管理后台实战 · 后端异步导出做好了,前端下载却总是失败?轮询进度 + 双形态下载一次搞定
- Node 后端实战 · 多租户 SaaS 怎么防止租户串数据?接口门禁 + 租户隔离 + 行级权限三层实战
- Node 后端实战 · 多租户数据隔离
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · 敏感数据防泄露 PII 脱敏与审计日志
- Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
- Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Node 后端实战 · 老系统数据迁移怎么不出乱子?V1→V2 重构实战与 3 个生产坑
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!