企业级后台实战(下):Vue 3 权限系统与动态路由深度解析

在上一篇《企业级后台实战(上)》中,我们已经把一个 Vue 后台项目最常见的基础骨架拼起来了:Vue Router 负责页面切换,Pinia 负责状态管理,Axios 负责网络通信,Element Plus 负责界面组件。

但如果你真的要把一个后台管理系统做成“企业可用”的产品,仅仅有静态的页面、路由和菜单还远远不够。真正决定系统是否能上线的关键能力,往往是下面这些:

  • 用户登录后,哪些页面可以看,哪些页面不能看
  • 左侧菜单是否根据当前角色动态生成
  • 某个按钮是否只允许管理员操作
  • 刷新页面后,权限状态能不能恢复
  • 后端返回的菜单和前端路由,如何建立映射关系

这,就是权限系统

它不是一个单独的 API,也不是一个简单的 if 判断,而是 登录状态、Pinia、Vue Router、Axios、动态菜单、按钮级权限 共同协作出来的一整套机制。

本文会带你系统梳理 Vue 3 后台项目中最常见、最关键的权限系统知识点,并给出一套适合中小型企业项目落地的实现思路。

1. 先建立认知:权限系统到底在解决什么问题

很多初学者第一次做后台项目时,会把“权限”简单理解成:

用户没登录就跳去登录页。

这当然没错,但这只是权限系统里最基础的一层。

一个完整的后台权限体系,通常至少包含 4 个维度:

  1. 登录鉴权:判断用户是否已登录,是否持有有效 Token。
  2. 页面权限:判断用户能不能进入某个页面。
  3. 菜单权限:判断侧边栏该显示哪些菜单。
  4. 按钮权限:判断某个操作按钮是否可见、可点。

你可以把它理解为:

  • 登录鉴权解决“你是不是这个系统的人”
  • 页面权限解决“你能不能进这扇门”
  • 菜单权限解决“系统该不该把这扇门展示给你”
  • 按钮权限解决“进门之后你能做哪些操作”

在真实项目里,这四层通常会同时存在。


2. 常见权限模型:RBAC 是后台系统的主流方案

企业后台里最常见的权限设计思路叫 RBAC(Role-Based Access Control,基于角色的访问控制)

它的核心思想是:

不直接给每个用户单独分配大量权限,而是先给用户分配角色,再给角色分配权限。

例如:

  • 用户 A 是“超级管理员”
  • 用户 B 是“运营”
  • 用户 C 是“财务”

不同角色拥有不同的菜单、页面和按钮权限。

2.1 一套最常见的数据关系

你可以先把后台权限系统想象成下面这张逻辑图:

用户 User
  -> 角色 Role
    -> 菜单权限 Menu Permission
    -> 按钮权限 Action Permission

更具体一点,后端往往会返回这些信息:

interface UserInfo {
  id: number
  username: string
  roles: string[]
  permissions: string[]
}

例如:

const userInfo: UserInfo = {
  id: 1,
  username: 'admin',
  roles: ['admin'],
  permissions: [
    'dashboard:view',
    'user:list',
    'user:create',
    'user:update',
    'user:delete'
  ]
}

其中:

  • roles 更偏“粗粒度身份”
  • permissions 更偏“细粒度操作能力”

这套模型非常适合前端做权限控制。

2.2 前端最常见的两种权限来源

在 Vue 项目里,权限数据一般有两种来源:

方案 A:后端直接返回按钮权限码和菜单树

优点:

  • 前端逻辑简单
  • 权限变更集中在后端控制
  • 更适合企业后台

缺点:

  • 前后端需要约定菜单结构和组件映射规则

方案 B:前端本地维护全部路由,根据角色进行过滤

优点:

  • 前端实现简单,适合教学和小型后台
  • 不依赖复杂后端配置

缺点:

  • 权限规则变化时,需要重新发版前端

如果是正式项目,通常更推荐:

后端返回用户权限码,前端根据权限码过滤路由与按钮。


3. 权限系统的整体流程图

在开始写代码前,先把整个流程理顺,后面你才不会东补一个守卫、西补一个 if

一个完整流程通常是这样的:

  1. 用户访问系统,先进入登录页。
  2. 登录成功后,后端返回 token
  3. 前端把 token 保存到 Pinia 和 localStorage
  4. 前端调用“获取当前用户信息”接口,拿到 rolespermissions
  5. 前端根据权限过滤异步路由,动态注册到 Vue Router。
  6. 侧边栏根据可访问路由动态渲染菜单。
  7. 页面内部根据按钮权限码控制“新增 / 编辑 / 删除”等操作显隐。
  8. 刷新页面时,从本地恢复 token,重新拉取用户信息和动态路由。

从技术角度看,这套流程至少会涉及:

  • router.beforeEach
  • Pinia 用户状态仓库
  • Axios 请求拦截器
  • 动态路由注册 router.addRoute()
  • 菜单渲染
  • 自定义权限指令或组合式函数

这就是为什么权限系统一直被认为是后台项目的核心专题。


4. 第一步:登录状态与 Token 管理

4.1 为什么 Token 是权限系统的起点

在前后端分离项目中,后端不会记住你当前浏览器页面里发生了什么,它只认一件事:

你后续每次请求有没有携带合法凭证。

这个凭证最常见的就是 Token。

所以登录成功后,前端第一件事不是“跳转首页”,而是:

  1. 保存 Token
  2. 让后续请求自动携带 Token
  3. 基于 Token 获取用户信息和权限信息

4.2 Pinia 中管理用户身份状态

建议把登录状态相关内容统一放在 user store 中管理:

import { defineStore } from 'pinia'
import { ref } from 'vue'

interface UserInfo {
  id: number
  username: string
  roles: string[]
  permissions: string[]
}

export const useUserStore = defineStore('user', () => {
  // token 刷新后优先从本地缓存恢复
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref<UserInfo | null>(null)

  function setToken(value: string) {
    token.value = value
    localStorage.setItem('token', value)
  }

  function clearAuth() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }

  async function getUserInfo() {
    // 真实项目里通常在这里请求用户角色和权限
    // 这里请求后端获取用户信息
    userInfo.value = {
      id: 1,
      username: 'admin',
      roles: ['admin'],
      permissions: ['dashboard:view', 'user:list', 'user:create']
    }
  }

  return {
    token,
    userInfo,
    setToken,
    clearAuth,
    getUserInfo
  }
})

这里有几个常用知识点必须掌握:

  • token 要同时保存在响应式状态和本地缓存中
  • userInfo 不能只靠本地缓存硬编码,刷新后最好重新拉接口确认
  • 退出登录时,不能只清空 token,还要清掉用户信息和动态路由状态

这些看起来不起眼,却是最容易造成权限 Bug 的地方。


5. 第二步:路由 Meta 设计要规范

权限系统做不好,很多时候不是逻辑不会写,而是一开始路由设计太乱

为了后续做菜单、面包屑、缓存和权限判断,路由的 meta 信息最好从一开始就设计清楚。

5.1 推荐的 meta 字段

interface RouteMeta {
  title?: string
  icon?: string
  hidden?: boolean
  requiresAuth?: boolean
  roles?: string[]
  permission?: string
  keepAlive?: boolean
}

你可以这样理解:

  • title:菜单名称、页面标题
  • icon:侧边栏图标
  • hidden:是否在菜单中隐藏
  • requiresAuth:是否需要登录才能访问
  • roles:哪些角色能访问
  • permission:进入页面所需的权限码
  • keepAlive:是否需要缓存页面

5.2 一份更接近企业项目的路由表示例

import type { RouteRecordRaw } from 'vue-router'

export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: {
          title: '工作台',
          icon: 'Odometer',
          permission: 'dashboard:view',
          keepAlive: true
        }
      },
      {
        path: 'users',
        name: 'UserList',
        component: () => import('@/views/users/index.vue'),
        meta: {
          title: '用户管理',
          icon: 'User',
          permission: 'user:list'
        }
      }
    ]
  }
]

一旦你把 meta 设计规范了,后续很多功能都能直接复用:

  • 菜单显示用 titleicon
  • 权限过滤用 permission
  • 缓存页面用 keepAlive
  • 访问控制用 requiresAuth

这就是关键知识点之一:

权限系统的核心,不只是写守卫,而是先把路由元信息设计成“可计算、可复用”的结构。


6. 第三步:全局路由守卫是权限入口

6.1 守卫应该做什么

router.beforeEach 是权限系统的总闸门。

它最常见的职责包括:

  1. 判断当前页面是否需要登录
  2. 判断本地是否有 Token
  3. 如果有 Token 但用户信息还没加载,则先获取用户信息
  4. 获取用户信息后,动态注册权限路由
  5. 如果用户没有访问资格,则跳到 403 或首页

6.2 一份常见的守卫实现思路

import router from './router'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'

const whiteList = ['/login']

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

  if (userStore.token) {
    if (to.path === '/login') {
      next('/')
      return
    }

    if (!userStore.userInfo) {
      try {
        // 先恢复用户信息,再生成动态路由
        await userStore.getUserInfo()
        await permissionStore.generateRoutes(userStore.userInfo.permissions)
        permissionStore.dynamicRoutes.forEach((route) => {
          router.addRoute(route)
        })
        // 用 replace 重新进入目标页,确保新路由生效
        next({ ...to, replace: true })
      } catch (error) {
        userStore.clearAuth()
        next('/login')
      }
      return
    }

    next()
    return
  }

  if (whiteList.includes(to.path)) {
    next()
  } else {
    next('/login')
  }
})

6.3 这段守卫背后的关键点

这段代码里,真正重要的不是语法,而是流程控制:

  • 有 Token 但没有用户信息:说明刷新页面后状态丢了,需要重新拉用户信息
  • 动态路由生成后要 next({ ...to, replace: true }):否则当前跳转可能匹配不到新路由
  • 用户信息获取失败要清空登录态:避免脏数据残留

很多项目的“刷新白屏”“刷新后菜单没了”“首次登录进不去页面”,本质上都出在这一层。


7. 第四步:动态路由生成与注册

权限系统里最有“实战含量”的部分,就是动态路由。

7.1 为什么要动态路由

如果所有人都能访问同样的页面,那其实根本不需要动态路由。

但在后台里,常见情况是:

  • 管理员能看到“用户管理”“角色管理”“系统配置”
  • 普通运营只能看到“内容管理”“订单管理”
  • 财务只能看到“财务报表”

这时就不能把所有菜单都静态写死。

7.2 前端过滤异步路由的实现

通常做法是:先维护一份“完整异步路由表”,再根据权限码过滤。

function hasPermission(route: RouteRecordRaw, permissions: string[]) {
  const permission = route.meta?.permission

  if (!permission) {
    return true
  }

  return permissions.includes(permission)
}

function filterAsyncRoutes(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes
    // 先过滤掉当前层级无权限的路由
    .filter((route) => hasPermission(route, permissions))
    .map((route) => {
      const currentRoute = { ...route }

      if (currentRoute.children) {
        // 子路由也要继续递归过滤
        currentRoute.children = filterAsyncRoutes(currentRoute.children, permissions)
      }

      return currentRoute
    })
}

然后在权限仓库里统一管理:

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { asyncRoutes } from '@/router/async-routes'

export const usePermissionStore = defineStore('permission', () => {
  const dynamicRoutes = ref<RouteRecordRaw[]>([])

  async function generateRoutes(permissions: string[]) {
    dynamicRoutes.value = filterAsyncRoutes(asyncRoutes, permissions)
  }

  return {
    dynamicRoutes,
    generateRoutes
  }
})

7.3 企业项目里更复杂的情况

如果你的项目是“后端返回菜单树”,那前端的任务就会变成:

  1. 接收后端返回的菜单配置
  2. 根据菜单里的 component 字段映射到本地真实组件
  3. 转成 RouteRecordRaw
  4. 通过 router.addRoute() 动态注册

例如后端可能返回:

[
  {
    "path": "/users",
    "name": "UserList",
    "component": "users/index",
    "meta": {
      "title": "用户管理",
      "permission": "user:list"
    }
  }
]

前端要做组件映射:

const viewModules = import.meta.glob('@/views/**/*.vue')

function resolveView(component: string) {
  return viewModules[`/src/views/${component}.vue`]
}

这类写法在中大型后台里非常常见。


8. 第五步:菜单权限和页面权限不是一回事

很多人会把“菜单显示”和“页面可访问”混为一谈,这是常见误区。

实际上:

  • 菜单权限:解决“侧边栏展示什么”
  • 页面权限:解决“URL 能不能直接访问”

为什么必须分开?

因为就算你把某个菜单隐藏了,用户仍然可以手动在地址栏输入 URL。

所以一个专业项目必须做到:

  • 菜单层面不展示无权限入口
  • 路由层面不允许直接访问无权限页面

这两层缺一不可。

8.1 动态菜单渲染示例

<template>
  <el-menu :default-active="$route.path" router>
    <template v-for="route in menuRoutes" :key="route.path">
      <el-menu-item
        v-if="!route.meta?.hidden"
        :index="route.path"
      >
        <span>{{ route.meta?.title }}</span>
      </el-menu-item>
    </template>
  </el-menu>
</template>
const menuRoutes = computed(() => permissionStore.dynamicRoutes)

这里的核心不是 v-for,而是:

菜单数据源应该来自“过滤后的可访问路由”,而不是手写一份单独菜单。

这样做的好处是:

  • 减少两套配置不一致
  • 菜单、面包屑、权限来源统一
  • 后续维护成本更低

9. 第六步:按钮级权限才是业务控制的细粒度核心

真正到了业务页面里,最常见的权限需求往往不是“能不能进页面”,而是:

  • 能不能看到“新增用户”
  • 能不能点击“删除订单”
  • 能不能导出 Excel
  • 能不能执行“审核通过”

这就是按钮级权限。

9.1 最简单的做法:模板中直接判断

<el-button
  v-if="userStore.userInfo?.permissions.includes('user:create')"
  type="primary"
>
  新增用户
</el-button>

这种写法能用,但有明显缺点:

  • 模板里到处是重复逻辑
  • 一旦权限判断规则变化,改起来麻烦

9.2 更推荐的做法:封装组合式函数

import { computed } from 'vue'
import { useUserStore } from '@/stores/user'

export function usePermission(permission: string) {
  const userStore = useUserStore()

  const hasPermission = computed(() => {
    // 根据当前用户权限码判断是否可操作
    return userStore.userInfo?.permissions.includes(permission) ?? false
  })

  return {
    hasPermission
  }
}

使用时:

const { hasPermission } = usePermission('user:delete')
<el-button v-if="hasPermission" type="danger">删除</el-button>

9.3 进一步封装:自定义指令 v-permission

如果项目里按钮权限非常多,还可以封装成指令:

import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'

export const permissionDirective: Directive = {
  mounted(el, binding) {
    const userStore = useUserStore()
    const permission = binding.value
    const hasPermission = userStore.userInfo?.permissions.includes(permission)

    if (!hasPermission) {
      // 没有权限时,直接移除对应按钮节点
      el.parentNode?.removeChild(el)
    }
  }
}

使用时会非常简洁:

<el-button v-permission="'user:delete'" type="danger">
  删除
</el-button>

这部分是权限系统里最常见的高频知识点之一。


10. 第七步:刷新页面后的权限恢复

权限系统里还有一个特别容易翻车的地方:

用户明明已经登录了,一刷新页面,菜单没了、路由没了、按钮权限也没了。

根本原因是:

  • Pinia 里的状态存在内存中
  • 浏览器刷新后,内存状态会被清空

10.1 正确的恢复思路

刷新页面后,不应该直接依赖“上一次内存里的 userInfo”,而应该:

  1. localStorage 恢复 token
  2. 如果有 token,重新请求用户信息
  3. 根据用户权限重新生成动态路由
  4. 重新注册路由后再继续导航

这也是为什么前面守卫里要有这段逻辑:

if (!userStore.userInfo) {
  await userStore.getUserInfo()
  await permissionStore.generateRoutes(userStore.userInfo.permissions)
}

10.2 是否需要把用户信息也持久化

可以,但不建议把它当成唯一数据源。

更专业的做法是:

  • 本地可以暂存,用于改善刷新体验
  • 但只要进入系统,还是要以接口返回的最新权限为准

因为角色和权限可能在后台被管理员随时修改。


11. 常见技术点与关键技术点总结

到这里,你会发现权限系统涉及的知识非常多。为了方便你建立完整认知,我们把它拆成两类。

11.1 常用知识点

这些是你做后台项目几乎一定会用到的内容:

  • router.beforeEach 全局前置守卫
  • Token 持久化存储
  • Pinia 管理用户信息和权限信息
  • 路由 meta 配置
  • 动态菜单渲染
  • 按钮级权限控制
  • 退出登录清理状态

11.2 关键知识点

这些是决定权限系统是否“专业、稳定、可维护”的核心:

  • 菜单权限和页面权限必须分层
  • 动态路由必须在获取用户信息后再注册
  • 刷新后必须重新恢复权限链路
  • 路由 meta 必须规范设计,不能临时乱塞字段
  • 按钮权限不要在模板里到处写重复逻辑
  • 前端权限控制提升的是用户体验,不是最终安全边界

最后这一点尤其重要。

前端做的权限控制,本质上更多是在:

  • 隐藏无权限入口
  • 提升交互体验
  • 减少误操作

但真正的安全校验,必须仍然由后端完成。哪怕前端把按钮隐藏了,后端接口也必须继续校验用户是否有操作资格。


12. 企业项目里的常见坑

12.1 刷新后 404

原因通常是:

  • 动态路由还没注册,页面就先匹配了

解决思路:

  • 获取完用户信息并注册动态路由后,再 next({ ...to, replace: true })

12.2 登录后菜单不更新

原因通常是:

  • 菜单数据不是从权限路由生成的
  • 路由变了,但菜单数据源还是旧的静态数组

12.3 退出登录后还能访问旧页面

原因通常是:

  • 只清了 Token,没有清用户信息和动态路由状态

12.4 动态路由重复注册

原因通常是:

  • 每次进入页面都在重复执行 router.addRoute()

解决思路:

  • 在权限仓库中增加“是否已生成路由”的标识
  • 或者只在首次加载用户信息时注册

12.5 只有前端拦截,没有后端兜底

这是最危险的错误。

无论前端写得多漂亮,后端接口依旧必须校验:

  • 当前用户身份是否合法
  • 当前用户是否具有该接口的操作权限

前端权限不能替代服务端安全控制。


13. 一套推荐的项目目录组织方式

为了让权限系统更清晰,建议相关代码按职责拆分:

src/
  ├── api/
  │   └── user.ts              # 登录、获取用户信息
  ├── router/
  │   ├── index.ts             # 静态路由、守卫注册
  │   ├── async-routes.ts      # 需要按权限过滤的异步路由
  │   └── permission.ts        # 路由过滤工具函数
  ├── stores/
  │   ├── user.ts              # token、用户信息
  │   └── permission.ts        # 动态路由、菜单状态
  ├── directives/
  │   └── permission.ts        # v-permission 指令
  ├── composables/
  │   └── usePermission.ts     # 权限判断组合式函数
  └── layout/
      └── index.vue            # 动态菜单布局

这样拆分后,整个权限链路会非常清楚:

  • 身份状态归 user store
  • 路由和菜单归 permission store
  • 页面拦截归 router guard
  • 按钮权限归 directivecomposable

这比把所有逻辑都塞进 router/index.ts 专业得多。


14. 总结

权限系统是 Vue 后台项目里最有代表性的企业级专题之一。

它看上去只是“登录后控制页面跳转”,实际上背后串起来的是:

  • 身份认证
  • 状态管理
  • 动态路由
  • 动态菜单
  • 按钮级权限
  • 刷新恢复
  • 前后端协作约定

如果你把这套链路真正做顺了,那么你就已经不再是在写“教学 Demo”,而是在写一套真正具备企业项目骨架的后台系统。

下一步去哪? 权限系统补完之后,后台项目还会遇到一批非常高频的专题能力,比如大文件上传与分片上传跨域问题与代理配置页面性能优化组件封装与权限指令复用。这些内容,才是真实前端岗位里最有价值的实战题。