学完这篇你能拿到一套能直接落地的 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
- 前端权限只是体验层,后端接口必须逐条校验