在上一篇《企业级后台实战(上)》中,我们已经把一个 Vue 后台项目最常见的基础骨架拼起来了:Vue Router 负责页面切换,Pinia 负责状态管理,Axios 负责网络通信,Element Plus 负责界面组件。
但如果你真的要把一个后台管理系统做成“企业可用”的产品,仅仅有静态的页面、路由和菜单还远远不够。真正决定系统是否能上线的关键能力,往往是下面这些:
- 用户登录后,哪些页面可以看,哪些页面不能看
- 左侧菜单是否根据当前角色动态生成
- 某个按钮是否只允许管理员操作
- 刷新页面后,权限状态能不能恢复
- 后端返回的菜单和前端路由,如何建立映射关系
这,就是权限系统。
它不是一个单独的 API,也不是一个简单的 if 判断,而是 登录状态、Pinia、Vue Router、Axios、动态菜单、按钮级权限 共同协作出来的一整套机制。
本文会带你系统梳理 Vue 3 后台项目中最常见、最关键的权限系统知识点,并给出一套适合中小型企业项目落地的实现思路。
1. 先建立认知:权限系统到底在解决什么问题
很多初学者第一次做后台项目时,会把“权限”简单理解成:
用户没登录就跳去登录页。
这当然没错,但这只是权限系统里最基础的一层。
一个完整的后台权限体系,通常至少包含 4 个维度:
- 登录鉴权:判断用户是否已登录,是否持有有效 Token。
- 页面权限:判断用户能不能进入某个页面。
- 菜单权限:判断侧边栏该显示哪些菜单。
- 按钮权限:判断某个操作按钮是否可见、可点。
你可以把它理解为:
- 登录鉴权解决“你是不是这个系统的人”
- 页面权限解决“你能不能进这扇门”
- 菜单权限解决“系统该不该把这扇门展示给你”
- 按钮权限解决“进门之后你能做哪些操作”
在真实项目里,这四层通常会同时存在。
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。
一个完整流程通常是这样的:
- 用户访问系统,先进入登录页。
- 登录成功后,后端返回
token。 - 前端把
token保存到 Pinia 和localStorage。 - 前端调用“获取当前用户信息”接口,拿到
roles和permissions。 - 前端根据权限过滤异步路由,动态注册到 Vue Router。
- 侧边栏根据可访问路由动态渲染菜单。
- 页面内部根据按钮权限码控制“新增 / 编辑 / 删除”等操作显隐。
- 刷新页面时,从本地恢复
token,重新拉取用户信息和动态路由。
从技术角度看,这套流程至少会涉及:
router.beforeEach- Pinia 用户状态仓库
- Axios 请求拦截器
- 动态路由注册
router.addRoute() - 菜单渲染
- 自定义权限指令或组合式函数
这就是为什么权限系统一直被认为是后台项目的核心专题。
4. 第一步:登录状态与 Token 管理
4.1 为什么 Token 是权限系统的起点
在前后端分离项目中,后端不会记住你当前浏览器页面里发生了什么,它只认一件事:
你后续每次请求有没有携带合法凭证。
这个凭证最常见的就是 Token。
所以登录成功后,前端第一件事不是“跳转首页”,而是:
- 保存 Token
- 让后续请求自动携带 Token
- 基于 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 设计规范了,后续很多功能都能直接复用:
- 菜单显示用
title和icon - 权限过滤用
permission - 缓存页面用
keepAlive - 访问控制用
requiresAuth
这就是关键知识点之一:
权限系统的核心,不只是写守卫,而是先把路由元信息设计成“可计算、可复用”的结构。
6. 第三步:全局路由守卫是权限入口
6.1 守卫应该做什么
router.beforeEach 是权限系统的总闸门。
它最常见的职责包括:
- 判断当前页面是否需要登录
- 判断本地是否有 Token
- 如果有 Token 但用户信息还没加载,则先获取用户信息
- 获取用户信息后,动态注册权限路由
- 如果用户没有访问资格,则跳到 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 企业项目里更复杂的情况
如果你的项目是“后端返回菜单树”,那前端的任务就会变成:
- 接收后端返回的菜单配置
- 根据菜单里的
component字段映射到本地真实组件 - 转成
RouteRecordRaw - 通过
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”,而应该:
- 从
localStorage恢复token - 如果有
token,重新请求用户信息 - 根据用户权限重新生成动态路由
- 重新注册路由后再继续导航
这也是为什么前面守卫里要有这段逻辑:
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 - 按钮权限归
directive或composable
这比把所有逻辑都塞进 router/index.ts 专业得多。
14. 总结
权限系统是 Vue 后台项目里最有代表性的企业级专题之一。
它看上去只是“登录后控制页面跳转”,实际上背后串起来的是:
- 身份认证
- 状态管理
- 动态路由
- 动态菜单
- 按钮级权限
- 刷新恢复
- 前后端协作约定
如果你把这套链路真正做顺了,那么你就已经不再是在写“教学 Demo”,而是在写一套真正具备企业项目骨架的后台系统。
下一步去哪? 权限系统补完之后,后台项目还会遇到一批非常高频的专题能力,比如大文件上传与分片上传、跨域问题与代理配置、页面性能优化、组件封装与权限指令复用。这些内容,才是真实前端岗位里最有价值的实战题。