Skip to content

路由与权限机制 ​

适用端: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)
权限 storesrc/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

核心机制 ​

三层权限,各管一段 ​

层判据不满足时的表现由谁决定
菜单 / 路由后端下发的 menus404 页(路由压根没注册)后端菜单 + 授权
按钮v-hasPermi="['域:资源:动作']"元素被 CSS 隐藏(打 data-permission-hidden 标记)前端比 userStore.permissions
字段★ useFieldPermission(datasetPermission)isVisible / isReadonly后端 my-field-modes
数据行后端行过滤规则返回的行数变少★ 定制权限引擎

未授权直达 URL 看到的是 404,不是 403

permission store#generateRoutes 在动态路由末尾追加了兜底路由:

ts
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 的核心分支(原文):

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 顶部一句决定了组件解析方式:

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 ​

tsx
// 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 ​

ts
// 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))

三处设计取舍,文件头注释写得很细,这里给结论:

  1. 打标记 + CSS 隐藏,不摘 DOM 节点。老实现 el.parentNode.removeChild(el) 只动 DOM 不动 vnode,留下游离锚点,Vue 打补丁时抛 NotFoundError: Failed to execute 'insertBefore' on 'Node'。注释里记录的实测数据:无权限账号在角色资源授权页来回切两次视图 = 29 条 JS 错误,超管 = 0 条——只有没权限的人踩得到。
  2. 不写 el.style.display。style / class 是 Vue 管的 prop,元素上有 :style / :class 绑定时,下一次 patch 会把 display 静默抹掉(偶发)。data-* 不出现在 vnode props 里,patchProp 不会碰。
  3. 有权限那一支必须显式 removeAttribute。指令在 mounted 与 updated 都会调用,而 userStore.permissions 是整体重新赋值的 Set;只写「无权限就 set」会变成「一旦隐藏就永久隐藏」的单向阀。

配套 CSS 在 src/styles/index.scss 末尾,两条必须成对存在:

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 成员配置)。

怎么扩展 ​

给一个新功能加权限,前后端各三步:

  1. 后端:菜单入库时写 system_menu.permission = 域:资源:动作,Controller 上 @PreAuthorize("@ss.hasPermission('域:资源:动作')");动作词沿用 query / create / update / delete / export;
  2. 前端:按钮加 v-hasPermi="['域:资源:动作']",值必须是数组(传字符串会 throw new Error(t('permission.hasPermission')));
  3. 验证:用一个没授权的角色登录,确认按钮消失且控制台无 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(.*)*'。两条都存在,前者是动态追加的、优先级更靠后;改其中一条前先想清楚另一条。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

相关页面 ​