路由与权限机制
适用端:Web | 类型:机制
前端不维护权限规则,只做三件事:把后端下发的菜单变成路由、按权限串隐藏按钮、按字段模式控制表单项。本页把这条链路走通,并说明「未授权用户直达 URL 会看到 404 而不是 403」的成因。
前置阅读:前端工程结构。
它解决什么问题
关键点:菜单是后端按权限过滤好的,前端拿到的就是「这个人能看的」,所以前端不做菜单级权限判断。
代码在哪
| 关注点 | 文件 |
|---|---|
| 路由实例与静态路由注册 | src/router/index.ts(47 行) |
| 静态路由表 | src/router/modules/remaining.ts(697 行) |
| 全局守卫 | src/permission.ts(82 行) |
| 菜单 JSON → 路由 | src/utils/routerHelper.ts(384 行,generateRoute / flatMultiLevelRoutes / getParentLayout) |
| 权限 store | src/store/modules/permission.ts |
| 用户 store(权限串) | src/store/modules/user.ts |
RouteMeta 类型 | types/router.d.ts |
| 按钮权限指令 | src/directives/permission/hasPermi.ts、hasRole.ts |
| 函数式判断 | src/utils/permission.ts(checkPermi / checkRole) |
| 隐藏样式 | src/styles/index.scss 末尾([data-permission-hidden]) |
| 菜单渲染 | src/layout/components/Menu/src/Menu.vue + components/useRenderMenuItem.tsx / useRenderMenuTitle.tsx |
| ★ 字段级权限 | src/hooks/web/useFieldPermission.ts |
核心机制
三层权限,各管一段
| 层 | 判据 | 不满足时的表现 | 由谁决定 |
|---|---|---|---|
| 菜单 / 路由 | 后端下发的 menus | 404 页(路由压根没注册) | 后端菜单 + 授权 |
| 按钮 | v-hasPermi="['域:资源:动作']" | 元素被 CSS 隐藏(打 data-permission-hidden 标记) | 前端比 userStore.permissions |
| 字段 | ★ useFieldPermission(datasetPermission) | isVisible / isReadonly | 后端 my-field-modes |
| 数据行 | 后端行过滤规则 | 返回的行数变少 | ★ 定制权限引擎 |
未授权直达 URL 看到的是 404,不是 403
permission store#generateRoutes 在动态路由末尾追加了兜底路由:
this.addRouters = routerMap.concat([{
path: '/:path(.*)*',
component: () => import('@/views/Error/404.vue'),
name: '404Page',
meta: { hidden: true, breadcrumb: false }
}])没有权限的路径不在 routerMap 里,于是命中兜底 → 渲染「抱歉,您访问的页面不存在」。/403 页确实存在(在 remaining.ts 的静态路由里),但这条链路不会走到它。写文档、做验收时别把 404 误判成「路由坏了」。
守卫只做四件事
src/permission.ts 的核心分支(原文):
router.beforeEach(async (to, from, next) => {
start(); loadStart()
if (getAccessToken()) {
if (to.path === '/login') { next({ path: '/' }) }
else {
if (!dictStore.getIsSetDict) { dictStore.setDictMap().then() } // 字典异步加载,不 await
if (!userStore.getIsSetUser) {
isRelogin.show = true
await userStore.setUserInfoAction() // 拉 user/roles/permissions/menus
await appStore.loadUserThemeConfig(userStore.getUser.id)
isRelogin.show = false
await permissionStore.generateRoutes() // 后端过滤菜单 → 路由
permissionStore.getAddRouters.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw)
})
const redirectPath = from.query.redirect
const redirect = typeof redirectPath === 'string' ? redirectPath : to.fullPath
const nextData = to.fullPath === redirect ? { ...to, replace: true }
: { ...parseRouteLocation(redirect), replace: true }
next(nextData) // 重新进一次,让新路由生效
} else { next() }
}
} else {
whiteList.indexOf(to.path) !== -1
? next()
: next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
})白名单只有 6 条:/login、/social-login、/auth-redirect、/bind、/register、/oauthLogin/gitee。
next({ ...to, replace: true }) 是必须的:addRoute 之后当前这次导航已经匹配过 404 了,重进一次才会命中新路由。
菜单缓存在 localStorage
setUserInfoAction 把 menus 写进 wsCache(key = CACHE_KEY.ROLE_ROUTERS = roleRouters),generateRoutes() 从缓存读。所以改了角色的菜单授权后,已登录用户要重新登录(或清缓存)才看得到变化——刷新页面不够,因为 isSetUser 仍为 true 时守卫会直接 next()。
菜单 JSON 怎么变成路由
routerHelper.ts 顶部一句决定了组件解析方式:
const modules = import.meta.glob('../views/**/*.{vue,tsx}')后端菜单的 component 字段是相对 src/views 的字符串(如 system/post/index),转换时靠 findIndex(ev => ev.includes(route.component)) 去 glob 结果里找匹配项。四条分支:
| 菜单形状 | 生成的路由 |
|---|---|
| 顶级 + 非目录 | component = Layout,真实组件塞进 children: [{ path: '', component }],name 加 Parent 后缀 |
| 目录 | component = parentId === 0 ? Layout : getParentLayout(),并算 redirect |
外链(isUrl(path)) | 包一层 IFrameView(@/views/IFrame/index.vue),meta.iframeSrc / meta.link |
| 普通菜单 | 从 glob 结果里按 component 或 path 匹配组件 |
getParentLayout() 返回的是 defineComponent({ name: 'ParentLayout', setup: () => () => h(RouterView) })——只透传子路由,不重复渲染 Layout。
meta 映射:{ title: route.name, icon: route.icon, hidden: !route.visible, noCache: !route.keepAlive, alwaysShow: … }。RouteMeta 的完整字段在 types/router.d.ts(hidden / alwaysShow / title / titleSuffix / icon / noCache / breadcrumb / affix / activeMenu / noTagsView / followAuth / canTo / hash / iframeSrc / link / params / query)——没有 permission 字段,别照着别的教程往里加。
最后 flatMultiLevelRoutes 把超过两级的路由摊平成两级(promoteRouteLevel 用临时 router 实现),因为多级嵌套会让 keep-alive 与面包屑失效。
顶级路由的 path 必须带前导 /
generateRoute 里对顶级路由做了兜底补 /,注释说明原因是补不上时 addRoute 会抛错、瘫痪全站。手工往 remaining.ts 加顶级路由时同样要注意。
菜单渲染只看 hidden
// useRenderMenuItem.tsx
const renderMenuItem = (routers: AppRouteRecordRaw[] = [], parentPath = '/') => {
return routers.filter((v) => !v.meta?.hidden).map((v) => { … })只有一个子节点且 alwaysShow !== true 时渲染 ElMenuItem,否则渲染 ElSubMenu 递归。图标由 useRenderMenuTitle.tsx 直接把 meta.icon 字符串(如 ep:home-filled)交给全局 Icon 组件解析。
按钮级:v-hasPermi
// src/directives/permission/hasPermi.ts
export const HIDDEN_ATTR = 'data-permission-hidden'
export function hasPermi(app: App<Element>) {
app.directive('hasPermi', (el, binding) => {
const { value } = binding
if (value && value instanceof Array && value.length > 0) {
const hasPermissions = hasPermission(value)
if (hasPermissions) { el.removeAttribute(HIDDEN_ATTR) }
else { el.setAttribute(HIDDEN_ATTR, '') }
} else { throw new Error(t('permission.hasPermission')) }
})
}
const all_permission = '*:*:*'
export const hasPermission = (permission: string[]) =>
userStore.permissions.has(all_permission) ||
permission.some((p) => userStore.permissions.has(p))三处设计取舍,文件头注释写得很细,这里给结论:
- 打标记 + CSS 隐藏,不摘 DOM 节点。老实现
el.parentNode.removeChild(el)只动 DOM 不动 vnode,留下游离锚点,Vue 打补丁时抛NotFoundError: Failed to execute 'insertBefore' on 'Node'。注释里记录的实测数据:无权限账号在角色资源授权页来回切两次视图 = 29 条 JS 错误,超管 = 0 条——只有没权限的人踩得到。 - 不写
el.style.display。style/class是 Vue 管的 prop,元素上有:style/:class绑定时,下一次 patch 会把 display 静默抹掉(偶发)。data-*不出现在 vnode props 里,patchProp不会碰。 - 有权限那一支必须显式
removeAttribute。指令在 mounted 与 updated 都会调用,而userStore.permissions是整体重新赋值的Set;只写「无权限就 set」会变成「一旦隐藏就永久隐藏」的单向阀。
配套 CSS 在 src/styles/index.scss 末尾,两条必须成对存在:
[data-permission-hidden] { display: none !important; }
/* display:none 的元素仍然是 CSS 兄弟,EP 的 .el-button+.el-button{margin-left:12px}
会让第一个看得见的按钮白拿 12px 左缩进,所以要归位 */
[data-permission-hidden] + .el-button,
[data-permission-hidden] + .el-dropdown { margin-left: 0 !important; }!important 是必需的:EP 给 .el-button 写的是 display: inline-flex,选择器权重同为 (0,1,0),靠加载顺序取胜太脆(注释点名了 main.ts 里 setupElementPlus、@/styles/index.scss、unocss 三者的 import 顺序)。
v-hasRole 复用同一个 HIDDEN_ATTR,判断 super_admin === role || roleFlag.includes(role);注释标明全库 0 处使用。
权限串从哪来
GET /system/auth/get-permission-info 返回体里的 permissions 数组(形如 system:post:create),存进 user store 的 permissions: Set<string>。超管拿到的是通配 *:*:*。
同一份数据也决定了后端 @PreAuthorize("@ss.hasPermission(...)") 的结果——前后端用的是同一套权限串,前端隐藏按钮只是体验,真正的闸门在后端。
定制扩展点
本项目在上游三层之外加了一整套「资源授权」体系,前端入口集中在这几处(详见开发手册「02 权限体系开发」章):
| 文件 | 职责 |
|---|---|
src/views/system/resource/ | 权限资源管理页;动作词表 GRANT_ACTION_OPTIONS 在 constants.ts |
src/views/system/rule/ | 行过滤规则(模板 + 参数) |
src/views/system/role/grant/ResourceTreePicker.vue | 资源树选择器 UI 层(搜索 / 字段级开关 / 勾选) |
src/views/system/role/grant/GrantEffectiveTree.vue | 角色「生效权限树」只读可视化(el-table-v2 虚拟表) |
src/views/system/role/grant/useResourceCatalog.ts | 资源目录数据层(树加载 + id 索引) |
src/views/system/role/grant/resourceTree.ts | 资源树纯函数(禁 import vue 与 @/api,可 vitest) |
src/views/system/role/grant/batchGrant.ts | 批量授权纯函数(前端笛卡尔展开、动作交集,上限 500 与后端 @Size 镜像) |
src/views/system/permissionDiag/ | ★ 权限诊断页 |
src/hooks/web/useFieldPermission.ts | 字段级权限:useFieldPermission(datasetPermission, column?) → { isVisible, isReadonly } |
src/api/system/grant/index.ts | 上述页面全部接口(/system/resource/*、/system/rule/*、/system/grant/*、/system/subject/*、/system/slot-mapping/*、/system/permission-diag/check) |
静态路由里也有一条定制入口:remaining.ts 的 /system/role 下有 SystemRoleGrantL(grant 资源授权 / member 成员配置)。
怎么扩展
给一个新功能加权限,前后端各三步:
- 后端:菜单入库时写
system_menu.permission=域:资源:动作,Controller 上@PreAuthorize("@ss.hasPermission('域:资源:动作')");动作词沿用query / create / update / delete / export; - 前端:按钮加
v-hasPermi="['域:资源:动作']",值必须是数组(传字符串会throw new Error(t('permission.hasPermission'))); - 验证:用一个没授权的角色登录,确认按钮消失且控制台无 JS 报错(这正是
data-permission-hidden方案要解决的问题),再直连接口确认后端返回code: 403。
需要在脚本或工具函数里判断权限时,用 src/utils/permission.ts 的 checkPermi(['域:资源:动作']),不要自己读 store。
坑与注意
- 改了授权不重新登录看不到效果:菜单缓存在 localStorage 的
roleRouters,且isSetUser为 true 时守卫直接放行。 v-hasPermi只管显示,不管能力:漏掉后端@PreAuthorize的接口,前端藏了按钮也照样能被直接调用。v-hasRole全库 0 处使用,不要新引入——角色判断在本项目的授权模型里已经被资源授权取代。useTable/useCrudSchemas没有调用点(上游遗留),别把它们当成「项目约定的权限入口」。router/index.ts的resetRouter()有白名单:['Redirect', 'RedirectRoot', 'Login', 'NoFound', 'Home']不会被移除。退出登录时调用它清动态路由;新增常驻静态路由时记得加进白名单,否则登出后跳不回去。router.onError会自动整页跳转:chunk 加载失败(发版后老 chunk 被删)时window.location.assign(to.fullPath)。用户表现为「点一下刷新了一次」,这是设计好的行为,不是 bug。- 404 兜底路由的
path是'/:path(.*)*',而remaining.ts里另有一条'/:pathMatch(.*)*'。两条都存在,前者是动态追加的、优先级更靠后;改其中一条前先想清楚另一条。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- axios 封装与 API 约定:token 怎么带、401 怎么处理;
- 通用组件与 hooks:
useFieldPermission之外的常用 hook; - 新增一个菜单功能端到端:菜单 SQL 与权限串的完整走查;
- 前端工程结构:目录职责与自动导入。
