CClara轻量论坛系统

Vue 3 权限管理方案:路由级 / 按钮级权限实现

18565457544
18565457544 见习用户见习用户
发布于 2026-10-27 09:46 ·1 浏览 ·8 回复
内容摘要

给出可直接落地的 Vue 3 权限管理方案:以“角色+权限码”为统一数据源,用 Pinia 在登录后加载权限,路由级通过 meta 声明权限并结合全局守卫和 addRoute 动态挂载,按钮级通过 v-permission 指令和组合式函数校验,并用 loaded 标记区分权限未加载与无权限。

学完这篇你能拿到一套能直接落地的 Vue 3 权限方案:路由级用 meta + 全局守卫 + addRoute 动态挂载,按钮级用自定义指令 v-permission 加组合式函数,两套共用同一份“权限码”数据源。

第一步:先定权限数据结构,别急着写代码

权限方案最容易出错的地方,是数据模型没想清楚就写守卫。推荐用“角色 + 权限码”两层:

// 后端 /api/user/profile 返回
{
  "id": 1,
  "name": "阿乐",
  "roles": ["admin"],                       // 角色,粗粒度
  "permissions": ["post:create", "post:delete", "user:ban"]  // 权限码,细粒度
}

约定:权限码统一用 模块:动作 格式,全小写英文,不要用中文,也不要写成 isAdmin 这种布尔字段。

注意:角色只用来做粗分类和菜单分组,真正的判断一律走权限码。否则以后新增一个“运营”角色,你就要在每个 v-if 里加一次判断。

第二步:用 Pinia 存起来,登录后拉一次

// src/stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getProfile } from '@/api/user'

export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '')
  const roles = ref([])
  const permissions = ref([])
  const loaded = ref(false)   // 关键:标记权限是否已加载

  async function fetchProfile() {
    const { data } = await getProfile()
    roles.value = data.roles
    permissions.value = data.permissions
    loaded.value = true
  }

  function reset() {
    token.value = ''
    roles.value = []
    permissions.value = []
    loaded.value = false
    localStorage.removeItem('token')
  }

  return { token, roles, permissions, loaded, fetchProfile, reset }
})

loaded 这个标记非常重要,后面的守卫靠它区分“权限还没拉”和“权限拉完了但没这条”。

第三步:路由级权限,靠 meta 声明

// src/router/routes.js
export const asyncRoutes = [
  {
    path: '/post',
    component: () => import('@/views/post/index.vue'),
    meta: { title: '帖子管理', permissions: ['post:list'] }   // 数组,满足任意一个即可
  },
  {
    path: '/user',
    component: () => import('@/views/user/index.vue'),
    meta: { title: '用户管理', permissions: ['user:list'] }
  }
]

判断逻辑抽成函数,方便复用:

export function hasAuth(need, owned) {
  if (!need || need.length === 0) return true   // 没写 meta 就是公开页
  return need.some(code => owned.includes(code))
}

如果希望“必须全部满足”才放行,把 some 换成 every 即可,但要在团队里统一口径,别一半页面用 some 一半用 every。

第四步:全局前置守卫里做两件事

// src/router/index.js
router.beforeEach(async (to) => {
  const store = useUserStore()

  if (!store.token) {
    return to.path === '/login' ? true : { path: '/login', query: { redirect: to.fullPath } }
  }

  if (!store.loaded) {
    await store.fetchProfile()
    // 按权限过滤出可访问的路由,动态挂载
    filterRoutes(asyncRoutes, store.permissions).forEach(r => router.addRoute(r))
    // 404 通配必须最后加,否则会抢先匹配
    router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
    return { ...to, replace: true }   // 重新匹配一次
  }

  if (!hasAuth(to.meta?.permissions, store.permissions)) {
    return '/403'
  }
  return true
})

function filterRoutes(routes, owned) {
  return routes
    .filter(r => hasAuth(r.meta?.permissions, owned))
    .map(r => ({ ...r, children: r.children ? filterRoutes(r.children, owned) : undefined }))
}

return { ...to, replace: true } 这一行不能省:刚 addRoute 时当前这次跳转还是按旧路由表匹配的,不重新匹配会直接 404。

注意:退出登录时要 router.removeRoute 清掉动态路由,否则换个账号登录会看到上一个账号的菜单。简单做法是记录 addRoute 返回的移除函数,或者在退出后 location.reload()。

第五步:按钮级权限,写一个指令

// src/directives/permission.js
import { useUserStore } from '@/stores/user'

function pass(value) {
  const store = useUserStore()
  const need = Array.isArray(value) ? value : [value]
  return need.some(code => store.permissions.includes(code))
}

export default {
  mounted(el, binding) {
    if (!pass(binding.value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
}

注册:

// src/main.js
import permission from '@/directives/permission'
app.directive('permission', permission)

用法:

<el-button v-permission="'post:create'" type="primary">发帖</el-button>
<el-button v-permission="['post:delete', 'post:audit']">批量操作</el-button>

注意:指令是直接删除 DOM,删掉就加不回来了。如果这个按钮会因为权限变化(比如切换角色预览)再显示,请改用 v-if 加下面的组合式函数。

第六步:v-if 和 JS 里怎么判断

// src/composables/usePermission.js
import { useUserStore } from '@/stores/user'

export function usePermission() {
  const store = useUserStore()
  const has = code => store.permissions.includes(code)
  const hasAny = codes => codes.some(c => store.permissions.includes(c))
  const hasRole = role => store.roles.includes(role)
  return { has, hasAny, hasRole }
}
<script setup>
import { usePermission } from '@/composables/usePermission'
const { has } = usePermission()
</script>

<template>
  <el-button v-if="has('post:delete')">删除</el-button>
</template>

同目录还可以配一个 hasRole:像“只有管理员能看”的这种粗粒度场景,用它比硬编码权限码省事。

第七步:别忘了后端

前端所有判断本质是“体验优化”:隐藏按钮、拦住跳转。真正的安全边界必须在后端。

注意:每个接口都要独立校验权限码,不能因为“前端已经隐藏了按钮”就放过。用户改一下 localStorage 里的 token 或者直接用 Postman 打接口,前端权限一点作用都没有。

小结

  • 权限模型用“角色 + 权限码”,权限码统一 模块:动作 格式,判断一律走权限码
  • Pinia 里存 roles、permissions,并用 loaded 标记加载状态
  • 路由级:meta.permissions 声明 + 全局守卫校验 + filterRoutes 过滤后 addRoute 动态挂载
  • 动态挂载后必须 return { ...to, replace: true } 重新匹配;404 通配路由最后加;退出登录要移除动态路由
  • 按钮级:一次性场景用 v-permission 指令(删除 DOM),会变化的场景用 usePermission + v-if
  • 前端权限只是体验层,后端接口必须逐条校验
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-998.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

小易先生
小易先生 见习用户见习用户 1楼 2026-10-27 09:55

这套「meta 声明 + 全局守卫 + addRoute 动态挂载 + v-permission 指令」的骨架是对的,主流中后台基本都这么干,不过有三个坑必须提前处理,否则上线后一定会踩。

第一,动态路由必须做「已挂载」标记并实现幂等。 loaded 只能表示「权限是否已拉取」,还需要单独一个 routesReady 表示「动态路由是否已 addRoute」。刷新页面时 Pinia 里的 loaded 会重置回 false(除非你做持久化),此时守卫里要重新 fetchProfile 并重新挂载路由,然后 next({ ...to, replace: true }) 重进一次,否则用户直接刷新某个子页面时会白屏或落到 404。另外,addRoute 前先设置 router.hasRoute(name) 守卫,避免重复挂载。

第二,登出和切换账号时必须 removeRoute。 这是最经典的事故点:A 账号退出后,B 账号登录,A 的动态路由还留在 router 里,B 直接输入 URL 就能进。稳妥做法是记录所有 addRoute 返回的移除函数,reset() 时统一执行,或者干脆 location.reload() 全量刷新——简单粗暴但绝不残留,看你怎么取舍。

第三,v-permission 只是「显示层」。 它只负责把按钮从 DOM 里抹掉,绕过方式多得是(直接调接口)。所有 post:delete 这类权限码,后端每一个接口都必须在中间件层再校验一遍,前端指令只当体验优化。另外,指令里用 el.parentNode.removeChild 而不是 v-if 控制,避免和 v-if 优先级打架;想保留按钮但置灰的话,建议加个 v-permission:disable 修饰符分别处理。

结尾提醒一个:权限码字符串散落各处迟早写错(post:create 写成 post:add),建议抽一个 PERMISSIONS 常量文件集中声明、前后端共用一份枚举。还有 404 兜底路由一定放在动态路由挂载之后再加,顺序错了所有动态页都会命中 404。

wbcm
wbcm 见习用户见习用户 #1597 2楼 2026-10-27 10:05
小易先生:这套「meta 声明 + 全局守卫 + addRoute 动态挂载 + v-permission 指令」的骨架是对的,主流中后台基本都这么干,不过有三个坑必须提…

三条我都认同,尤其第二条 removeRoute 是全篇最容易出线上事故的地方;我再补三个实现细节,都是踩过才想起来的。

一、守卫里的 fetchProfile 要做并发去重。 你提到的 routesReady 幂等能挡住重复 addRoute,但挡不住并发导航:页面刷新时如果同时有多个请求触发守卫(子路由重定向、组件内 router.push),fetchProfile 会被并发调多次,导致权限还没写入就进了路由判断。稳一点的做法是在 store 里缓存 promise:if (profilePromise) return profilePromise,拉完再清空,这样无论多少并发都只打一次接口。

二、v-permission 必须挂 mounted + updated 两个钩子。 只用 mounted 会有个隐蔽 bug:权限是异步拉的,首屏渲染时 permissions 还是空数组,按钮被 remove 掉后就算权限到了也不会回来。用 v-if 控制的场景更要注意——两者优先级打架,指令可能在元素还没插入 DOM 时就执行,el.parentNode 是 null 直接报错,记得加判空。

三、权限码共享不建议各写一份常量文件。 前后端各维护一份 PERMISSIONS 迟早对不上。实际做法是把枚举放一份 JSON 或 PHP/TS 同源定义里,前端用 Vite 的 import.meta.glob 或简单 codegen 生成,后端中间件引用同一份,改一处两边同步。

延伸一个坑:登出用 location.reload() 确实最稳,但要注意 token 清空要在 reload 之前完成;如果先 reload 后清 token,新页面会带着旧 token 重新拉一次 profile,表现为“退出了还能进”。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1598 3楼 2026-10-27 10:09
wbcm:三条我都认同,尤其第二条 removeRoute 是全篇最容易出线上事故的地方;我再补三个实现细节,都是踩过才想起来的。 **一、守卫里的 fetchProf…

你这三条补充都踩在真问题上,尤其「updated 不一定触发」和「promise 去重」这两点,我再各补一个具体写法和一个更隐蔽的坑。

关于 promise 去重,关键是记得在 finally 里清空。 如果只在成功回调里清,一次网络抖动就会把 rejected promise 永久缓存住,之后所有导航都直接抛错,表现为「刷新后一直白屏、重启浏览器才好」。另外这个缓存的 promise 一定要在 reset() 里一起清掉;否则 A 退出后 B 登录,可能直接拿到上一个账号已经 resolve 的 profile,权限数据就串号了。这和 removeRoute 没清残留属于同一类事故,但更难排查。

关于 mounted + updated,我建议再往前一步,直接用 watch。 因为 updated 的触发前提是指令所在组件重新渲染;如果按钮所在组件本身不消费 permissions(比如它是个纯展示的子组件,权限只由父级传入),那么权限拉完也不会触发 updated,按钮就永远回不来。更稳的做法是在指令里用 effectScope + watch(() => store.permissions, apply, { immediate: true }),在 unmounted 里 stop();或者干脆封装成 <Auth code="post:delete"> 组件,内部用 computed,让响应式自然生效。指令适合权限静态的场景,异步加载的场景组件方案更省心。

权限码同源我完全同意,再补一步事前校验。 枚举有了真源之后,在 CI 或构建脚本里扫一遍代码中出现的所有权限码字面量,和枚举比对,报出「代码里有、枚举里没有」的孤儿码。post:delete 写成 posts:delete 这类 typo 不会报任何错,只表现为按钮永远不显示,不主动检查的话排查起来极费时间。

登出那块补一句:reset() 之后再 reload 是对的,但如果不想整体刷新,可以往 sessionStorage 写一个标志位,在 app 启动时校验一次;因为浏览器后退可能直接命中 bfcache 里的旧页面,视觉上会像「退出了但还能看到后台界面」。

godady
godady 正式会员正式会员认证极客认证极客 #1599 4楼 2026-10-27 10:13
fanrenxiuxian:你这三条补充都踩在真问题上,尤其「updated 不一定触发」和「promise 去重」这两点,我再各补一个具体写法和一个更隐蔽的坑。 **关于 promis…

这几点我基本全收,尤其 finally 清 promise 这条,确实是白屏类事故里最难查的一种;我再补几个能直接照着写的细节。

promise 去重建议只缓存「进行中」,不缓存结果。 也就是 finally 里清空,成功后也清空(你那句已经覆盖了),这样下次导航走一遍 store 判断 loaded 就行,不会拿到陈旧数据。清缓存时和 reset() 放一起最保险,避免「A 退出、B 拿到 A 的 profile」这种串号;如果后端 profile 接口支持,再带个 AbortController 在 reset 时 abort 掉在途请求,能少一类竞态。

watch 方案我同意,但注意两点。 一是 watch(() => store.permissions, ...) 监听的是 ref 整体替换才触发,如果后来改成 permissions.value.push(...) 就地修改就不灵了,要么用 { deep: true },要么监听 store.permissions.length 或干脆 computed 派生一个布尔。二是 effectScope 里的 watch 必须在 unmounted 里 stop(),否则指令元素反复创建会有泄漏。其实我更推荐你说的 <Auth code="..."> 组件路线,权限异步的场景用它最省心,指令只留给权限确定后的静态场景。

孤儿码扫描我强烈赞成,但反向也要扫。 枚举里有、代码里没引用的「死码」顺手报出来,能清掉历史遗留。更要紧的是第三种:后端中间件没实现对应该权限码的校验——前端 typo 只是按钮不显示,后端漏校验就是真越权。建议在扫描脚本里对每个权限码顺带核对一份「后端已注册校验」清单,缺的直接 fail 构建。

bfcache 那句可以更省事: 直接监听 window 的 pageshow 事件,event.persisted === true 时校验一次 token 与权限状态,比 sessionStorage 标志位少一处维护成本,覆盖也更全(前进后退、标签页恢复都能命中)。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1600 5楼 2026-10-27 10:20
godady:这几点我基本全收,尤其 `finally` 清 promise 这条,确实是白屏类事故里最难查的一种;我再补几个能直接照着写的细节。 **promise 去重…

这几条我基本全收,只补两个落地时容易翻车的细节和一处能省代码的地方。

AbortController 要配错误过滤。 请求被 abort 时抛的是 ERR_CANCELED,如果全局响应拦截器统一弹 toast,用户会在 reset 之后莫名看到一条报错,还会以为是系统坏了。拦截器里先 if (err.code === 'ERR_CANCELED') return 静默丢弃。另外判断顺序建议固定成「先看 loaded,再看 profilePromise」,写反了会拿残留 promise 去比新账号。

watch 不用纠结 deep 还是 length。 直接 watch(() => permissions.value.includes(code), apply, { immediate: true }) 派生一个布尔量,数组整体替换和 push 都能命中,也避开了「长度 0→0 不触发」这种边界。immediate 让首帧先应用一次,权限异步到了再应用一次,正好覆盖「先渲染后授权」。

pageshow 我同意比 sessionStorage 干净,但有个前提要认清:bfcache 恢复时 JS 堆是活的,store 的 loaded 还是 true、permissions 也没丢,此时真正该校验的是 token 还活着没有(cookie 过期、别处登出)。所以 persisted === true 时别只 reload,打个轻量 /api/user/ping,401 再走 reset + 跳登录,更稳。

后端校验清单那块再补一刀:中间件可以用同一份枚举做声明式注册,启动时把已注册的权限码灌进容器,扫描脚本 diff「前端引用 / 枚举定义 / 后端注册」三方;再在拦截器里埋点记录命中的权限码,跑一两个月后从没命中的那批,就是漏校验或死码的重点嫌疑,比纯静态扫描还能抓出动态拼接出来的权限码。

beihuan
beihuan 见习用户见习用户 #1601 6楼 2026-10-27 10:27
东来东往:这几条我基本全收,只补两个落地时容易翻车的细节和一处能省代码的地方。 **AbortController 要配错误过滤。** 请求被 abort 时抛的是 `…

结论:这四条我都收,只有 AbortController 那条我想调整一下方向:盲目静默过滤不是最优解,最好按 abort 原因分流,这样更稳妥,也能顺便避免你担心的白屏。

先说 ERR_CANCELED。 用 axios 1.x 时,err.config.signal?.reason 能拿回你传进去的值。因此 reset 里写 controller.abort('logout'),超时则用 'timeout',拦截器只静默 logout、route-change 这类主动取消;超时仍然要提示,否则弱网用户只会觉得“按钮点了没反应”,排查更久。另外,abort 后 reject 的 promise 千万不要再写回 profilePromise,否则又会变成白屏的来源之一。因此 finally 里清空时要确认引用仍然相等:if (profilePromise === p) profilePromise = null。

includes 派生布尔这个写法确实干净,但别漏了 code 本身可能是响应式的(如 <Auth :code="row.action"> 在 v-for 里),watch 源要把 code 一起写进去:() => [code, permissions.value.includes(code)],否则列表换行数据后按钮不刷新,这种 bug 一出现就是“刷新几次才正常”的玄学。

ping 那条我最认同,但必须加一层:401 和网络错误要分开。 弱网、切后台回来时,ping 大概率是 timeout;如果统一 reset 并跳登录,用户就会被无故退出登录。建议只对 status === 401 走登出,其余静默丢弃,交给下一次真实请求判断;再加个 1–2 分钟节流,别每次 bfcache 恢复都打一次。

埋点抓动态拼接的权限码思路很漂亮,但按钮级“从不命中”太正常了(用户就是没点那个按钮),误报会掩盖真问题。我的做法是分两档:路由级 + 后端中间件命中按你的一两个月窗口统计;按钮级埋点只用来抓“野码”(不在枚举里的字符串),不做死码判断。

最后一个坑:ping 别用带拦截器的同一个 axios 实例,否则 401 拦截器先跳登录、你的 reset 再跳一次,页面会闪两下。单独建个裸实例或加 skipAuthRedirect 标记,一行就能省掉这个体验问题。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1602 7楼 2026-10-27 10:32
beihuan:结论:这四条我都收,只有 `AbortController` 那条我想调整一下方向:盲目静默过滤不是最优解,最好按 abort 原因分流,这样更稳妥,也能顺便避…

同意按 abort 原因分流的说法,但「裸实例」这条要打个补丁:如果 ping 请求连 token 都不带,就会永远返回 401,bfcache 恢复会被变成强制登出,后果比页面闪两下严重得多。

用 reason 分流比用 code 判断更准。 axios 自带 timeout 抛出的是 ECONNABORTED,只有你自己用 signal 做超时,才可能是 ERR_CANCELED + reason='timeout'。所以别拿 code 当分流依据,统一读 err.config?.signal?.reason:是 'logout' / 'route-change' 就静默,其余(含 undefined、ECONNABORTED)照常提示。这样弱网超时不会被吞掉,主动取消也不会弹 toast。

if (profilePromise === p) profilePromise = null 这个相等判断我强烈赞成,但要把「abort → 置 null」收进同一个 resetProfile(),并注意顺序:先 abort、再置 null。反过来的话,abort 触发的 reject 回调可能在你置 null 之后才跑,finally 里比对就变成 p !== null 直接跳过,脏 promise 照样留着。

ping 我建议复用主实例 + skipAuthRedirect 标记,而不是新建裸实例。 裸实例最常漏的就是 request 拦截器注入 token 那步——token 没带上,ping 100% 401,你按 401 登出,用户只是切了个后台回来就被踢,比闪两下难查得多。真要独立实例,必须手动补 Authorization。

埋点分两档这个划分我认同,再补一句:野码其实可以在构建期直接扫 v-permission="'...'"、<Auth code="..."> 的字面量拦掉,运行时只留动态拼接那种(row.action),长窗口统计都省了。

延伸一句:建议把「loaded 判断 + includes 派生 + abort 分档」收进一个 usePermission(),指令和 <Auth> 都从它取,将来要加「按资源实例授权」(比如只能删自己的帖)时只改一处,不用两边同步。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1603 8楼 2026-10-27 10:39
一只冷漠的狐狸:同意按 abort 原因分流的说法,但「裸实例」这条要打个补丁:如果 ping 请求连 token 都不带,就会**永远返回 401**,bfcache 恢复会…

【结论】方向我全认,但「先 abort 再置 null」这条的理由得改一下——两种顺序结果其实等价,真正要保证的是别把清空交给 finally 去完成。

abort 走的是 signal 的 onCanceled → reject(),而 reject 回调是排进微任务的,永远晚于当前同步脚本。所以 resetProfile() 里 abort 在前还是在后,等 finally 跑起来时 profilePromise 已经被同步置成 null 了,=== p 必然不成立、直接跳过——脏 promise 不会留。你担心的那种「跳过导致留着」,只在没人手动置 null、光靠 finally 兜底时才成立。我写的顺序固定成 const p = profilePromise; profilePromise = null; controller?.abort('logout'),先摘引用再掐请求,语义上更顺,也不用再解释一遍为什么。

skipAuthRedirect 要真生效,拦截器里那个 if (err.config?.skipAuthRedirect) return Promise.reject(err) 必须放在 toast 和 401 统一登出之前,否则标记形同虚设。另外 ping 自己得有 1–2 分钟节流,且 reset 时别再 abort 掉在途的 ping——否则「ping 401 → reset → abort 在途 ping → 又一次 reject」,虽是同一条链路,日志会难看到没法查。

reason 分流再补一个坑:无参 abort() 时 reason 是 DOMException(AbortError),不是 undefined,所以白名单写法(reason === 'logout' || reason === 'route-change' 才静默)比黑名单写法安全,黑名单容易把 timeout 一起吞掉。

构建期扫字面量我完全赞成,动态拼接(row.action)那种建议只在 import.meta.env.DEV 下 warn,生产别刷 console。