第 25 章:权限控制
学习目标
- 区分路由权限与按钮权限
- 用动态路由实现 RBAC
- 写
v-permission指令 - 避坑:动态路由刷新丢失
一、权限模型
| 维度 | 粒度 | 存储 |
|---|---|---|
| 路由 | 页面 | 后端返回菜单 |
| 按钮 | DOM | 角色权限码 |
| 接口 | API | 后端校验 |
二、路由权限
ts
// src/router/index.ts
const baseRoutes = [
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/403', component: () => import('@/views/403.vue') },
]
export const routes = baseRoutests
// 动态挂载
router.beforeEach(async (to) => {
const user = useUserStore()
if (!user.token) return to.path === '/login' ? true : { path: '/login' }
if (!user.menuLoaded) {
const menus = await api.getMenus()
menus.forEach(m => router.addRoute(m))
user.menuLoaded = true
return to.fullPath // 再次跳转
}
})⚠️ 坑 1:
addRoute之后必须return to.fullPath重新跳转,否则新路由不生效。
三、按钮权限:角色 store
ts
export const useUserStore = defineStore('user', () => {
const roles = ref<string[]>([])
const permissions = ref<string[]>([])
const hasPermission = (code: string) => permissions.value.includes(code)
return { roles, permissions, hasPermission }
})四、v-permission 指令
ts
// src/directives/permission.ts
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'
export const permission: Directive<HTMLElement, string> = {
mounted(el, binding) {
const user = useUserStore()
if (!user.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
}ts
app.directive('permission', permission)vue
<button v-permission="'user:delete'">删除</button>⚠️ 坑 2:
removeChild后 Vue 不会清理,推荐用el.style.display = 'none'。
五、组件级控制
vue
<template>
<el-button v-if="userStore.hasPermission('user:delete')">删除</el-button>
</template>六、菜单渲染
ts
const menu = computed(() => {
return userStore.permissions.map(code => ({
code,
title: permissionMap[code]?.title,
path: permissionMap[code]?.path,
}))
})vue
<el-menu>
<el-menu-item v-for="item in menu" :key="item.code" :index="item.path">
{{ item.title }}
</el-menu-item>
</el-menu>七、刷新页面丢失动态路由
ts
// src/main.ts
const user = useUserStore()
if (user.token && !user.menuLoaded) {
await loadMenus()
}或者用 localStorage 缓存菜单数据。
💡 main.ts 是"启动前准备",守卫是"跳转时拦截"——刷新页面时,守卫跑不到(先撞 404),所以动态路由必须在
main.ts里await加载完再mount。时序对比:
触发时机 main.ts 路由守卫 啥时候跑 App 启动前 用户跳转时 刷新页面会跑 ✅ 会 ❌ 不会(先 404,守卫没机会) 用途 初次加载动态资源 每次跳转鉴权
⚠️ 坑 3:不要在
beforeEach里 await 一个还没初始化的 store,会陷入循环。
八、登录流程
ts
const login = async (form: LoginForm) => {
const { token } = await api.login(form)
user.token = token
const menus = await api.getMenus()
menus.forEach(m => router.addRoute(m))
user.menuLoaded = true
router.push('/')
}💡 3 个加载点接力,各管一段——任意路径都不漏:
加载点 时机 作用 登录函数( login())用户点登录按钮后 首次主动加载 main.ts每次刷新 / 启动 主力——刷新场景全靠它 路由守卫( beforeEach)任何跳转时 保险——网络挂 / 异常路径兜底 3 个场景对照:
场景 谁干活 用户首次登录 登录函数(主动加载) 刷新页面 main.ts(主力加载) 刷新时 main.ts 加载失败 路由守卫(兜底救场) 守卫一定会跑(刷新 / 直输 URL / 点链接都算跳转),但
!menuLoaded平时是 false(被 main.ts 提前处理了),正常不重复加载,异常才救场。3 个场景完整时序:
场景 A:用户首次登录
点登录按钮 ↓ login() 函数 ├── await api.login(form) → token ├── await api.getMenus() → menus ├── menus.forEach(router.addRoute) ← ① 主动加载 ├── user.menuLoaded = true └── router.push('/') ↓ beforeEach 守卫跑 ↓ !menuLoaded === false → 跳过 ← ② 不重复加载场景 B:刷新页面
F5 刷新 ↓ main.ts 执行 ├── if (token && !menuLoaded) await loadMenus() ← ① 主力加载 ├── router.addRoute(...) ├── user.menuLoaded = true └── app.mount('#app') ↓ 首次导航,beforeEach 跑 ↓ !menuLoaded === false → 跳过 ← ② 正常不跑场景 C:刷新时 main.ts 加载失败(异常)
F5 刷新 ↓ main.ts loadMenus() 网络挂了 → 失败,menuLoaded 还是 false ↓ app.mount('#app')(代码继续往下走) ↓ 首次导航,beforeEach 跑 ↓ !menuLoaded === true → 守卫兜底再次加载 ← ③ 保险救场 ✓
九、403 处理
ts
router.beforeEach((to) => {
if (to.meta.requiresAuth && !user.hasPermission(to.meta.permission as string)) {
return { path: '/403' }
}
})十、本章小结
| 维度 | 实现 |
|---|---|
| 路由 | router.addRoute |
| 按钮 | v-permission |
| 组件 | v-if + store |
| 刷新 | Pinia 持久化 + 重新挂载 |
动手练习
- 动态菜单:登录后根据角色返回菜单挂载
- 按钮权限:用
v-permission控制删除按钮 - 403 页面:无权访问跳 403
下一章:第 26 章:性能优化 →