Vue Router 导航守卫的权限控制:多角色路由拦截方案

Vue Router 导航守卫的权限控制:多角色路由拦截方案

权限控制是后台系统的标配,但很多人写到最后,路由守卫里堆满了 if-else,角色判断散得到处都是,新增一个角色要把整个文件改一遍。

我经历过两个阶段:第一个阶段是什么都在组件里判断,导致每个页面都有重复的权限校验代码;第二个阶段是用动态路由,但注册时机混乱,白名单配置和路由表混在一起。

最后摸索出一套还算稳定的方案,核心思路是:路由表声明权限,守卫统一校验,角色匹配用位运算

先说问题:常见的权限守卫写法有什么问题

问题一:守卫里塞满业务逻辑

router.beforeEach(async (to, from) => {
  const userStore = useUserStore()

  // 非登录页但没 token
  if (!userStore.token && to.name !== 'Login') {
    return { name: 'Login' }
  }

  // 普通用户不能访问 admin 页面
  if (userStore.role === 'user' && to.path.startsWith('/admin')) {
    return { name: 'Forbidden' }
  }

  // 编辑不能访问财务模块
  if (userStore.role === 'editor' && to.path.startsWith('/finance')) {
    return { name: 'Forbidden' }
  }

  // VIP 用户才能访问某些页面
  if (to.meta.vipOnly && userStore.level < 1) {
    return { name: 'Upgrade' }
  }

  // ... 越来越长
})

这种写法当角色和页面多了以后,守卫就变成了一坨难以维护的意大利面条。

问题二:路由表和权限定义分离

// 路由表里
{ path: '/admin/users', component: UserManage, meta: { role: 'admin' } }

// 守卫里又要判断一遍
if (to.meta.role && userStore.role !== to.meta.role) {
  return { name: 'Forbidden' }
}

角色定义了两份,迟早会不一致。

问题三:动态路由注册时机混乱

// 有时候在登录后注册,有时候在守卫里注册
async function registerRoutes() {
  const menus = await fetchMenus()
  menus.forEach(menu => {
    router.addRoute(menu) // 动态添加
  })
}

刷新页面时,如果用户已登录,守卫先触发,但路由还没注册完,导致 404。

我的方案:路由表声明权限,守卫统一校验

第一步:定义权限标志

// permissions.ts
export const Permission = {
  // 基础权限
  VIEW: 1 << 0,        // 1   - 查看
  CREATE: 1 << 1,      // 2   - 创建
  EDIT: 1 << 2,        // 4   - 编辑
  DELETE: 1 << 3,      // 8   - 删除

  // 模块权限
  FINANCE_VIEW: 1 << 4,    // 16  - 财务模块查看
  FINANCE_EDIT: 1 << 5,     // 32  - 财务模块编辑
  ADMIN_USER: 1 << 6,       // 64  - 用户管理
  ADMIN_ROLE: 1 << 7,       // 128 - 角色管理

  // 特殊权限
  VIP_CONTENT: 1 << 8,      // 256 - VIP 内容
  ALL: -1,                  // 所有权限(用于 superadmin)
} as const

export type PermissionKey = keyof typeof Permission
export type PermissionValue = typeof Permission[PermissionKey]

用位掩码的好处是:权限可以叠加,可以做交集判断,性能也比字符串数组快。

第二步:定义角色

// roles.ts
import { Permission } from './permissions'

interface Role {
  id: string
  name: string
  permissions: number
}

export const Roles = {
  GUEST: {
    id: 'guest',
    name: '访客',
    permissions: Permission.VIEW,
  } as Role,

  USER: {
    id: 'user',
    name: '普通用户',
    permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT,
  } as Role,

  EDITOR: {
    id: 'editor',
    name: '编辑',
    permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT | Permission.DELETE,
  } as Role,

  FINANCE: {
    id: 'finance',
    name: '财务',
    permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT | Permission.DELETE | Permission.FINANCE_VIEW | Permission.FINANCE_EDIT,
  } as Role,

  ADMIN: {
    id: 'admin',
    name: '管理员',
    permissions: Permission.ALL,
  } as Role,
}

export type RoleId = keyof typeof Roles

第三步:在路由表里声明所需权限

// router/routes.ts
import { Permission } from '@/permissions'
import { Roles } from '@/roles'

export const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
    meta: {
      permissions: Permission.VIEW,
    },
  },
  {
    path: '/admin',
    name: 'Admin',
    component: AdminLayout,
    meta: {
      permissions: Permission.VIEW,
    },
    children: [
      {
        path: 'users',
        name: 'AdminUsers',
        component: UserManage,
        meta: {
          permissions: Permission.ADMIN_USER,
        },
      },
      {
        path: 'roles',
        name: 'AdminRoles',
        component: RoleManage,
        meta: {
          permissions: Permission.ADMIN_ROLE,
        },
      },
    ],
  },
  {
    path: '/finance',
    name: 'Finance',
    component: FinanceLayout,
    meta: {
      permissions: Permission.FINANCE_VIEW,
    },
    children: [
      {
        path: 'overview',
        name: 'FinanceOverview',
        component: FinanceOverview,
        meta: {
          permissions: Permission.FINANCE_VIEW,
        },
      },
      {
        path: 'edit',
        name: 'FinanceEdit',
        component: FinanceEdit,
        meta: {
          permissions: Permission.FINANCE_EDIT,
        },
      },
    ],
  },
  {
    path: '/vip',
    name: 'VIP',
    component: VIPLayout,
    meta: {
      permissions: Permission.VIP_CONTENT,
    },
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
    meta: {
      // 登录页不需要权限,且作为白名单
      requiresAuth: false,
      permissions: 0,
    },
  },
]

第四步:守卫统一校验

// router/guards.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { useAuthStore } from '@/stores/auth'
import { Permission } from '@/permissions'

export const router = createRouter({
  history: createWebHistory(),
  routes,
})

// 判断是否有权限
function hasPermission(userPermissions: number, requiredPermissions: number): boolean {
  // superadmin
  if (userPermissions === Permission.ALL) return true
  // 需要的权限是 0 表示不需要权限(如登录页)
  if (requiredPermissions === 0) return true
  // 按位与判断
  return (userPermissions & requiredPermissions) === requiredPermissions
}

// 递归检查路由及其父路由的权限
function checkRoutePermission(route: RouteLocationNormalized): boolean {
  const authStore = useAuthStore()
  const userRole = authStore.role

  if (!userRole) return false

  const userPermissions = Roles[userRole].permissions
  const requiredPermissions = route.meta.permissions ?? Permission.VIEW

  return hasPermission(userPermissions, requiredPermissions)
}

router.beforeEach(async (to, from) => {
  const authStore = useAuthStore()

  // 白名单路由直接放行
  if (to.meta.requiresAuth === false) {
    return true
  }

  // 未登录先尝试从 localStorage 恢复
  if (!authStore.isLoggedIn) {
    authStore.initFromStorage()
  }

  // 仍未登录,跳转登录页
  if (!authStore.isLoggedIn) {
    return {
      name: 'Login',
      query: { redirect: to.fullPath },
    }
  }

  // 权限校验
  if (!checkRoutePermission(to)) {
    // 没有权限,看看有没有 403 页面
    if (router.hasRoute('Forbidden')) {
      return { name: 'Forbidden' }
    }
    // 没有 403 页面,返回首页
    return from.fullPath ? false : { name: 'Home' }
  }

  return true
})

动态路由怎么处理

有些系统是登录后从后端获取菜单,然后动态注册路由。这种场景需要解决注册时机的问题。

// stores/permission.ts
export const usePermissionStore = defineStore('permission', () => {
  const routesAdded = ref(false)
  const dynamicRoutes = ref<RouteRecordRaw[]>([])

  async function fetchAndAddRoutes() {
    if (routesAdded.value) return

    // 从后端获取菜单
    const menus = await fetchUserMenus()
    const processed = processMenus(menus) // 转换为自己系统的路由格式
    dynamicRoutes.value = processed

    processed.forEach(route => {
      router.addRoute(route)
    })

    routesAdded.value = true
  }

  function reset() {
    routesAdded.value = false
    dynamicRoutes.value = []
  }

  return { routesAdded, dynamicRoutes, fetchAndAddRoutes, reset }
})

守卫里:

router.beforeEach(async (to, from) => {
  const authStore = useAuthStore()
  const permissionStore = usePermissionStore()

  // 白名单
  if (to.meta.requiresAuth === false) {
    return true
  }

  // 确保已登录
  if (!authStore.isLoggedIn) {
    authStore.initFromStorage()
  }

  if (!authStore.isLoggedIn) {
    return {
      name: 'Login',
      query: { redirect: to.fullPath },
    }
  }

  // 动态注册路由(只执行一次)
  if (!permissionStore.routesAdded) {
    await permissionStore.fetchAndAddRoutes()
    // 重新触发当前导航,让它走到新注册的路由
    return to.fullPath
  }

  // 权限校验
  if (!checkRoutePermission(to)) {
    return { name: 'Forbidden' }
  }

  return true
})

关键是:守卫里执行动态注册后,要用 return to.fullPath 重新触发当前导航,这样新路由才能被匹配到。

按钮级别的权限指令

路由守卫只能控制页面级访问,按钮级别的权限控制需要另外实现:

// directives/permission.ts
import { Directive } from 'vue'
import { Permission } from '@/permissions'
import { useAuthStore } from '@/stores/auth'
import { Roles } from '@/roles'

export const vPermission: Directive = {
  mounted(el, binding) {
    const authStore = useAuthStore()
    const requiredPermission = binding.value as number

    const userRole = authStore.role
    if (!userRole) {
      el.parentNode?.removeChild(el)
      return
    }

    const userPermissions = Roles[userRole].permissions

    if (userPermissions === Permission.ALL) {
      return // superadmin 什么都能看
    }

    const has = (userPermissions & requiredPermission) === requiredPermission
    if (!has) {
      el.parentNode?.removeChild(el)
    }
  },
}

使用方式:

<template>
  <button v-permission="Permission.DELETE">删除</button>
  <button v-permission="Permission.ADMIN_USER">用户管理</button>
</template>

总结

权限控制方案的核心要点:

  1. 权限用位掩码表示:便于叠加和判断,性能好
  2. 角色定义成常量对象:方便新增角色
  3. 路由表声明所需权限:权限定义在一处
  4. 守卫统一校验:不在业务组件里散落权限判断
  5. 动态路由注意时机:确保路由注册完成后再进行匹配

如果你现在的守卫已经有几百行了,可以考虑按这个思路重构一次。改动不小,但后续维护会轻松很多。

最后更新 4/20/2026, 6:02:32 AM