Skip to content
第 25 章 前端 ⏱ 13 分钟阅读

第 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 = baseRoutes
ts
// 动态挂载
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 持久化 + 重新挂载

动手练习 ​

  1. 动态菜单:登录后根据角色返回菜单挂载
  2. 按钮权限:用 v-permission 控制删除按钮
  3. 403 页面:无权访问跳 403

下一章:第 26 章:性能优化 →

本站基于 VitePress 构建 · 由 StackHub 团队维护