页面级别的权限控制,在企业级 AI 网关项目里推荐用”后端返回路由 + 前端动态挂载”的方案:后端按用户角色返回可访问的菜单与路由,前端拿到后通过 addRoute 动态注册,配合全局路由守卫统一校验,未授权路径一律踢到 403 或登录页。静态写死路由的方案只适合内部小工具,一旦角色膨胀到几十个、菜单要按客户定制,改一次权限就要发一次版,根本扛不住。下面把我们在这套网关里落地的完整做法展开讲。
一、权限控制的分层模型
页面级权限只是权限体系的一层。网关项目里权限按粒度分成三层,每层有各自的控制点:
| 层级 | 控制对象 | 实现方式 |
|---|---|---|
| 页面级 | 路由地址 | 动态路由 + 路由守卫 |
| 菜单级 | 侧边栏菜单项 | 后端返回菜单树,前端渲染 |
| 按钮级 | 页面内操作 | 自定义指令 v-permission |
页面级控制解决”能不能进这个页面”,按钮级控制解决”进了页面能不能点这个按钮”。两者配合才是一套完整权限。
二、为什么选动态路由而非静态路由
静态路由把全部路由一次性注册,在 meta 里标记 roles,跳转时校验。它的缺陷在网关项目里暴露得很明显:普通用户也能在浏览器里看到所有路由定义,通过地址栏直插越权页,只是前端不渲染而已;菜单藏在代码里,客户要定制菜单必须重新编译。
| 对比项 | 静态路由 | 动态路由 |
|---|---|---|
| 路由来源 | 前端代码写死 | 后端按角色返回 |
| 越权访问 | 需守卫拦截,遗漏即漏 | 未注册即不存在,天然不可达 |
| 菜单定制 | 重新发版 | 后端配置即可 |
| 首次加载 | 全量打包 | 常驻路由 + 按需挂载 |
| 适用场景 | 小型内部系统 | 企业级多角色系统 |
动态路由下,无权限的路径根本没被注册,访问时直接落到 404,安全性更高。
三、动态路由的实现步骤
3.1 后端返回路由与权限码
登录后网关接口返回角色、权限码列表和路由表。路由表结构大致如下,前端只负责把 component 字符串映射成真实组件:
// 后端返回的示例结构
const asyncRoutes = [
{
path: '/gateway/upstream',
name: 'UpstreamManage',
component: 'gateway/UpstreamManage', // 字符串,前端映射组件
meta: { title: '上游管理', icon: 'server' },
},
{
path: '/gateway/models',
name: 'ModelConfig',
component: 'gateway/ModelConfig',
meta: { title: '模型配置', perm: 'gateway:model:edit' },
},
]
3.2 权限 store 生成可挂载路由
前端维护一份”路由表到组件的映射”,把字符串 component 替换成 import 进来的组件,再按 meta.perm 过滤当前用户无权访问的路由:
// stores/permission.ts
import { defineStore } from 'pinia'
const componentMap = {
'gateway/UpstreamManage': () => import('@/views/gateway/UpstreamManage.vue'),
'gateway/ModelConfig': () => import('@/views/gateway/ModelConfig.vue'),
}
export const usePermissionStore = defineStore('permission', {
state: () => ({ routes: [] as any[] }),
actions: {
async generateRoutes(userPerms: string[]) {
const { asyncRoutes } = await getUserRoutes()
const accessible = asyncRoutes.filter(
(r) => !r.meta?.perm || userPerms.includes(r.meta.perm),
)
this.routes = accessible.map((r) => ({
...r,
component: componentMap[r.component],
}))
return this.routes
},
},
})
3.3 路由守卫统一校验
全局前置守卫 beforeEach 承担三道判断:是否登录、是否需要拉权限、目标路由是否在权限内。注意动态路由挂载后要 next({ ...to, replace: true }) 重新触发一次导航,否则首次跳转会 404:
import router from '@/router'
const whiteList = ['/login', '/403']
router.beforeEach(async (to) => {
const auth = useAuthStore()
const perm = usePermissionStore()
if (!auth.isLoggedIn) {
return whiteList.includes(to.path) ? true : { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login') return { path: '/' }
if (perm.routes.length === 0) {
try {
const userPerms = await fetchUserPermissions()
const routes = await perm.generateRoutes(userPerms)
routes.forEach((r) => router.addRoute(r))
return { ...to, replace: true } // 重新导航,保证动态路由生效
} catch {
auth.logout()
return { path: '/login' }
}
}
return true
})
路由挂载完,侧边栏菜单直接遍历 perm.routes 渲染,菜单与路由同源,不会出现”菜单有但路由没有”的错位。
四、按钮级权限用指令收敛
页面级挡住了路由,但”模型配置”页里的”删除模型”按钮未必所有人都有权点。按钮级控制我们封装成 v-permission 指令,绑定的权限码在用户权限列表中才保留元素:
import type { Directive } from 'vue'
export const permission: Directive = {
mounted(el, binding) {
const required = binding.value as string
const auth = useAuthStore()
if (required && !auth.permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
},
}
在模板里这样用:
// 模板中:删除按钮加 v-permission="'gateway:model:delete'"
// 无该权限码时,元素挂载后被直接移除,不触发任何点击事件
页面上几十个按钮都走这一条指令,权限判断逻辑只写一次。
五、白名单、403 与 404 的兜底
动态路由方案下”无权限”有两种表现:已登录但访问了不在自己路由表里的路径,落到 404;明确的越权操作,跳 403 页。我们约定 404 对应”路由不存在或无权访问”,403 对应”已知资源但无权限”,路由守卫和接口响应两处都做兜底。白名单只放 login、403、404 三个路径,其余全部要求登录态,避免任何绕过口子。
六、登出与权限变更时的路由回收
权限是动态的,不能只做”挂载”不做”回收”。用户登出时,把动态挂载的路由逐个 removeRoute,再清空权限 store,防止下一账号登录时残留上一账号的路由与菜单。管理员在后台调整角色权限后,前端收到提示即触发重新拉取,把旧路由移除、按新权限重新 addRoute。这套流程在网关项目里对接的是”模型可见范围””上游管理权”这类高频变更场景,回收做得干净,才不会出现权限被降级后页面仍可访问的问题。
// stores/permission.ts
actions: {
resetRoutes() {
this.routes.forEach((r) => router.removeRoute(r.name))
this.routes = []
},
}
路由守卫里已经保证”动态路由为空就重新生成”,所以回收后下一次导航会自动重建,不需要额外状态标记。
常见问题(FAQ)
Q1:动态路由刷新页面后为什么会 404?
刷新后路由被清空,守卫重新拉取并 addRoute,期间首次导航未命中,需返回重定向。
Q2:菜单和路由不一致怎么办?
让菜单直接遍历已挂载路由生成,保持单一数据源,从结构上杜绝错位。
Q3:按钮权限一定要做吗?
页面级只能挡路由,按钮级挡操作,两者缺一个都会出现可见但不可用的界面。