RBAC权限模型两种实现方案对比:前端控制路由(intelligence) vs 后端全控路由(all) 完整代码实战
📋 目录
背景介绍
在企业管理后台中,权限控制是最核心的功能之一。不同的用户角色需要看到不同的菜单、访问不同的页面、操作不同的按钮。
典型的权限场景:
管理员(Admin):
├── 可以看到所有菜单(系统管理、用户管理、角色管理...)
├── 可以访问所有页面
└── 可以操作所有按钮(新增、编辑、删除、导出...)
普通用户(User):
├── 只能看到部分菜单(个人中心、数据查询...)
├── 只能访问授权页面
└── 只能操作部分按钮(查询、导出,不能删除)
vue-admin-better提供了两种权限控制模式:
- 前端控制路由(intelligence模式):前端维护路由表,根据用户角色过滤
- 后端控制路由(all模式):后端返回用户的路由配置,前端动态生成
这两种模式各有优劣,适用于不同的安全需求和业务场景。
问题分析
典型症状
在实际项目中,权限控制不当通常表现为:
症状1:未授权用户能访问敏感页面
// 用户直接在浏览器输入URL
http://localhost:8080/system/user-management
// ❌ 即使是普通用户,也能访问系统管理页面
// 因为路由是静态注册的,没有权限校验
症状2:菜单显示与实际权限不一致
// 左侧菜单隐藏了"用户管理"
// 但用户可以直接通过URL访问
// 或者通过浏览器开发者工具找到隐藏的路由
症状3:按钮权限失效
<template>
<!-- 理论上应该只有管理员能看到删除按钮 -->
<el-button @click="handleDelete">删除</el-button>
<!-- ❌ 但普通用户也能看到并点击 -->
</template>
症状4:权限变更后仍使用旧权限
// 用户在登录后,管理员将其角色从Admin改为User
// 但用户刷新页面后,仍然拥有Admin的所有权限
// 因为权限信息缓存在前端,没有实时校验
根本原因
通过深入分析,可以发现这些问题的根本原因是:
1. 权限信息来源不明确
前端不知道用户的真实权限:
├── 角色信息存储在哪里?(Vuex/Pinia、localStorage、Cookie)
├── 如何获取用户的角色?(登录接口、单独的角色接口)
├── 角色变更后如何同步?(轮询、WebSocket、手动刷新)
└── 谁来负责权限校验?(前端、后端、还是两者都有)
2. 路由注册时机不当
// ❌ 错误做法:应用启动时注册所有路由
const router = createRouter({
routes: [
{ path: '/dashboard', component: Dashboard },
{ path: '/system/user', component: UserManagement }, // 所有人都能访问
{ path: '/system/role', component: RoleManagement } // 所有人都能访问
]
})
// 正确做法:登录后根据角色动态添加路由
router.addRoute({
path: '/system/user',
component: UserManagement,
meta: { roles: ['admin'] } // 只有admin角色能访问
})
3. 权限粒度不统一
不同层级的权限控制缺失:
├── 路由级:能否访问某个页面
├── 菜单级:能否看到某个菜单项
├── 按钮级:能否点击某个操作按钮
└── 数据级:能否查看某条数据记录
如果只控制了路由级,用户仍能通过直接调用API操作数据
影响范围
权限控制失效的影响非常严重:
| 影响维度 | 具体表现 |
|---|---|
| 安全性 | 未授权用户访问敏感数据,造成数据泄露 |
| 合规性 | 违反最小权限原则,无法通过安全审计 |
| 用户体验 | 看到无法访问的菜单,点击后报错 |
| 可维护性 | 权限逻辑分散在各处,难以统一管理 |
解决方案:RBAC权限模型
核心设计原则
为了解决上述问题,我们采用RBAC(Role-Based Access Control)权限模型,遵循以下设计原则:
原则1:单一事实来源(Single Source of Truth)
用户的权限信息只有一个来源:
├── intelligence模式:前端维护的角色-路由映射表
├── all模式:后端返回的用户路由配置
└── 前端不自行猜测或硬编码权限
原则2:动态路由注册(Dynamic Route Registration)
路由不是一次性注册,而是根据用户权限动态添加:
├── 登录前:只有公开路由(登录页、404页)
├── 登录后:根据角色添加授权路由
└── 登出后:移除所有动态路由
原则3:多层防护(Multi-layer Protection)
权限控制在多个层级同时生效:
├── 路由级:路由守卫拦截未授权访问
├── 菜单级:只显示有权限的菜单项
├── 按钮级:隐藏或禁用无权限的操作按钮
└── API级:后端再次校验权限(最终防线)
原则4:权限缓存与同步(Cache & Sync)
权限信息需要合理缓存并及时同步:
├── 登录后缓存到Pinia/Vuex和localStorage
├── 刷新页面时从localStorage恢复
├── 提供强制刷新接口(管理员变更角色后)
└── 可选:WebSocket推送权限变更通知
两种模式对比
| 维度 | intelligence模式(前端控制) | all模式(后端控制) |
|---|---|---|
| 权限来源 | 前端维护路由表和角色映射 | 后端返回用户的路由配置 |
| 安全性 | ⭐⭐(较低,前端可篡改) | ⭐⭐⭐⭐⭐(高,后端控制) |
| 灵活性 | ⭐⭐⭐⭐(前端自由定制) | ⭐⭐⭐(依赖后端接口) |
| 性能 | ⭐⭐⭐⭐⭐(无需请求后端) | ⭐⭐⭐⭐(需额外请求) |
| 适用场景 | 内部系统、低风险场景 | 金融系统、高风险场景 |
| 开发成本 | 低(前端独立完成) | 中(需后端配合) |
| 维护成本 | 中(前后端路由表需同步) | 低(后端统一管理) |
| 典型应用 | 企业内部管理系统 | 银行、电商后台 |
架构设计图
┌─────────────────────────────────────────────────┐
│ UI Components │
│ (Menu, Button, Page, Dialog) │
└──────────────┬──────────────────────────────────┘
│ v-permission="['admin']"
│ checkPermission('user:delete')
▼
┌─────────────────────────────────────────────────┐
│ Permission Controller │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ 路由守卫(Router Guard) │ │
│ │ - 检查token │ │
│ │ - 检查角色 │ │
│ │ - 动态添加路由 │ │
│ ├──────────────────────────────────────────┤ │
│ │ 菜单过滤(Menu Filter) │ │
│ │ - 递归过滤路由树 │ │
│ │ - 生成可见菜单 │ │
│ ├──────────────────────────────────────────┤ │
│ │ 按钮权限(Button Permission) │ │
│ │ - 自定义指令v-permission │ │
│ │ - 工具函数checkPermission │ │
│ └──────────────────────────────────────────┘ │
└──────────────┬──────────────────────────────────┘
│
┌───────┴────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Intelligence │ │ All │
│ Mode │ │ Mode │
│ │ │ │
│ 前端维护路由 │ │ 后端返回路由 │
│ 角色映射表 │ │ 配置JSON │
└──────────────┘ └──────────────┘
完整实现
方案A:前端控制路由(intelligence模式)
核心思路:前端维护完整的路由表和角色映射关系,登录后根据用户角色过滤路由。
步骤1:定义路由表与角色映射
src/router/constantRoutes.js
/**
* 公开路由(无需登录即可访问)
*/
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/Login.vue'),
hidden: true
},
{
path: '/404',
component: () => import('@/views/404.vue'),
hidden: true
},
{
path: '/',
redirect: '/dashboard'
}
];
/**
* 动态路由(根据角色动态添加)
*
* meta.roles: 允许访问该路由的角色列表
* - admin: 管理员
* - editor: 编辑
* - user: 普通用户
*/
export const asyncRoutes = [
{
path: '/dashboard',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard/index',
meta: {
title: '仪表盘',
icon: 'Dashboard'
},
children: [
{
path: 'index',
component: () => import('@/views/Dashboard.vue'),
meta: {
title: '工作台',
icon: 'HomeFilled',
roles: ['admin', 'editor', 'user'] // 所有角色可访问
}
}
]
},
{
path: '/system',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/system/user',
meta: {
title: '系统管理',
icon: 'Setting',
roles: ['admin'] // 仅管理员可访问
},
children: [
{
path: 'user',
component: () => import('@/views/System/UserList.vue'),
meta: {
title: '用户管理',
icon: 'User',
roles: ['admin'],
permissions: ['user:view', 'user:create', 'user:update', 'user:delete']
}
},
{
path: 'role',
component: () => import('@/views/System/RoleList.vue'),
meta: {
title: '角色管理',
icon: 'Lock',
roles: ['admin'],
permissions: ['role:view', 'role:create', 'role:update', 'role:delete']
}
},
{
path: 'menu',
component: () => import('@/views/System/MenuList.vue'),
meta: {
title: '菜单管理',
icon: 'Menu',
roles: ['admin'],
permissions: ['menu:view', 'menu:create', 'menu:update', 'menu:delete']
}
}
]
},
{
path: '/data',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/data/list',
meta: {
title: '数据管理',
icon: 'DataAnalysis'
},
children: [
{
path: 'list',
component: () => import('@/views/Data/DataList.vue'),
meta: {
title: '数据列表',
icon: 'List',
roles: ['admin', 'editor'], // 管理员和编辑可访问
permissions: ['data:view']
}
},
{
path: 'export',
component: () => import('@/views/Data/DataExport.vue'),
meta: {
title: '数据导出',
icon: 'Download',
roles: ['admin', 'editor'],
permissions: ['data:export']
}
}
]
},
{
path: '/profile',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/profile/index',
meta: {
title: '个人中心',
icon: 'UserFilled'
},
children: [
{
path: 'index',
component: () => import('@/views/Profile.vue'),
meta: {
title: '个人信息',
icon: 'InfoFilled',
roles: ['admin', 'editor', 'user'], // 所有角色可访问
hidden: true // 不在菜单中显示
}
}
]
},
// 通配符路由(必须最后添加)
{
path: '/:pathMatch(.*)*',
redirect: '/404',
hidden: true
}
];
步骤2:实现路由过滤逻辑
src/router/permission.js
import { constantRoutes, asyncRoutes } from './constantRoutes';
/**
* 判断用户是否有访问某个路由的权限
* @param {Array} roles - 用户拥有的角色列表
* @param {Object} route - 路由配置
* @returns {Boolean}
*/
function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role));
}
// 没有配置roles,默认所有人可访问
return true;
}
/**
* 递归过滤路由表
* @param {Array} routes - 原始路由表
* @param {Array} roles - 用户角色列表
* @returns {Array} 过滤后的路由表
*/
export function filterAsyncRoutes(routes, roles) {
const result = [];
routes.forEach(route => {
// 创建路由副本,避免修改原始数据
const tmp = { ...route };
if (hasPermission(roles, tmp)) {
// 如果有子路由,递归过滤
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
result.push(tmp);
}
});
return result;
}
/**
* 根据用户角色生成可访问的路由表
* @param {Array} roles - 用户角色列表
* @returns {Array} 可访问的路由表
*/
export function generateAccessibleRoutes(roles) {
let accessedRoutes;
// admin角色拥有所有权限
if (roles.includes('admin')) {
accessedRoutes = asyncRoutes || [];
} else {
// 其他角色根据roles字段过滤
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
}
return accessedRoutes;
}
步骤3:路由守卫与动态路由注册
src/permission.js
import router from './router';
import store from './store';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
// 白名单(无需登录即可访问)
const whiteList = ['/login', '/404'];
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
// 开启进度条
NProgress.start();
// 设置页面标题
document.title = to.meta.title || 'Vue Admin Better';
// 获取token
const hasToken = store.getters.token;
if (hasToken) {
// 已登录
if (to.path === '/login') {
// 如果已登录,跳转到首页
next({ path: '/' });
NProgress.done();
} else {
// 判断是否已获取用户信息
const hasRoles = store.getters.roles && store.getters.roles.length > 0;
if (hasRoles) {
// 已获取角色信息,直接放行
next();
} else {
try {
// 获取用户信息
const { roles } = await store.dispatch('user/getUserInfo');
// 根据角色生成可访问的路由表
const accessRoutes = await store.dispatch('permission/generateRoutes', roles);
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route);
});
// 确保添加的路由生效,重新导航
next({ ...to, replace: true });
} catch (error) {
// 获取用户信息失败,清除token并跳转登录页
await store.dispatch('user/resetToken');
ElMessage.error(error.message || 'Has Error');
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
}
} else {
// 未登录
if (whiteList.indexOf(to.path) !== -1) {
// 在白名单中,直接进入
next();
} else {
// 不在白名单中,跳转登录页
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
});
// 全局后置钩子
router.afterEach(() => {
// 关闭进度条
NProgress.done();
});
步骤4:Pinia状态管理
src/store/modules/permission.js
import { defineStore } from 'pinia';
import { asyncRoutes, constantRoutes } from '@/router/constantRoutes';
import { generateAccessibleRoutes } from '@/router/permission';
/**
* 过滤路由的辅助函数
*/
function hasPermission(route, roles) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role));
}
return true;
}
function filterAsyncRoutes(routes, roles) {
const res = [];
routes.forEach(route => {
const tmp = { ...route };
if (hasPermission(tmp, roles)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [], // 完整路由表(包含公开路由和动态路由)
addRoutes: [], // 动态添加的路由表
sidebarRoutes: [] // 侧边栏菜单路由
}),
getters: {
// 获取完整路由表
getRoutes: (state) => state.routes,
// 获取侧边栏路由
getSidebarRoutes: (state) => state.sidebarRoutes
},
actions: {
/**
* 根据用户角色生成路由表
* @param {Array} roles - 用户角色列表
* @returns {Promise<Array>}
*/
generateRoutes(roles) {
return new Promise(resolve => {
let accessedRoutes;
// admin角色拥有所有权限
if (roles.includes('admin')) {
accessedRoutes = asyncRoutes || [];
} else {
// 其他角色根据roles字段过滤
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
}
// 保存动态路由
this.addRoutes = accessedRoutes;
// 合并公开路由和动态路由
this.routes = constantRoutes.concat(accessedRoutes);
// 提取用于侧边栏的路由(排除hidden的路由)
this.sidebarRoutes = accessedRoutes.filter(route => !route.hidden);
resolve(accessedRoutes);
});
},
/**
* 重置路由
*/
resetRoutes() {
this.routes = [];
this.addRoutes = [];
this.sidebarRoutes = [];
}
}
});
src/store/modules/user.js
import { defineStore } from 'pinia';
import { login, logout, getUserInfo } from '@/api/user';
import { getToken, setToken, removeToken } from '@/utils/auth';
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),
name: '',
avatar: '',
roles: [] // 用户角色列表
}),
getters: {
getToken: (state) => state.token,
getRoles: (state) => state.roles
},
actions: {
/**
* 登录
* @param {Object} userInfo - 登录信息
* @returns {Promise<void>}
*/
async login(userInfo) {
const { username, password } = userInfo;
try {
const { data } = await login({ username, password: password.trim() });
// 保存token
this.token = data.token;
setToken(data.token);
} catch (error) {
throw error;
}
},
/**
* 获取用户信息
* @returns {Promise<Object>}
*/
async getUserInfo() {
try {
const { data } = await getUserInfo();
if (!data) {
throw new Error('Verification failed, please Login again.');
}
const { roles, name, avatar } = data;
// roles必须是非空数组
if (!roles || roles.length <= 0) {
throw new Error('getUserInfo: roles must be a non-null array!');
}
this.roles = roles;
this.name = name;
this.avatar = avatar;
return { roles, name, avatar };
} catch (error) {
throw error;
}
},
/**
* 登出
* @returns {Promise<void>}
*/
async logout() {
try {
await logout(this.token);
// 清除本地状态
this.token = '';
this.roles = [];
this.name = '';
this.avatar = '';
// 清除token
removeToken();
} catch (error) {
throw error;
}
},
/**
* 重置token
*/
resetToken() {
return new Promise(resolve => {
this.token = '';
this.roles = [];
removeToken();
resolve();
});
}
}
});
方案B:后端控制路由(all模式)
核心思路:后端根据用户角色返回该用户可访问的路由配置(JSON格式),前端解析并动态生成路由。
步骤1:后端返回的路由配置格式
后端接口响应示例
{
"code": 200,
"message": "success",
"data": {
"routes": [
{
"path": "/dashboard",
"component": "Layout",
"redirect": "/dashboard/index",
"meta": {
"title": "仪表盘",
"icon": "Dashboard"
},
"children": [
{
"path": "index",
"component": "dashboard/index",
"name": "Dashboard",
"meta": {
"title": "工作台",
"icon": "HomeFilled"
}
}
]
},
{
"path": "/system",
"component": "Layout",
"redirect": "/system/user",
"meta": {
"title": "系统管理",
"icon": "Setting"
},
"children": [
{
"path": "user",
"component": "system/user/index",
"name": "User",
"meta": {
"title": "用户管理",
"icon": "User"
}
},
{
"path": "role",
"component": "system/role/index",
"name": "Role",
"meta": {
"title": "角色管理",
"icon": "Lock"
}
}
]
}
]
}
}
步骤2:前端解析后端路由配置
src/router/permission.js(all模式版本)
import Layout from '@/layouts/MainLayout.vue';
/**
* 组件映射表(用于动态导入)
*/
const modules = import.meta.glob('../views/**/*.vue');
/**
* 将后端返回的component字符串转换为实际的组件对象
* @param {String} component - 组件路径字符串
* @returns {Component}
*/
function loadComponent(component) {
// 特殊处理:Layout
if (component === 'Layout') {
return Layout;
}
// 动态导入组件
const componentPath = `../views/${component}.vue`;
const module = modules[componentPath];
if (!module) {
console.warn(`组件不存在: ${componentPath}`);
return () => import('@/views/404.vue');
}
return module;
}
/**
* 将后端返回的路由配置转换为Vue Router可用的路由对象
* @param {Array} routes - 后端返回的路由配置
* @returns {Array} Vue Router路由对象
*/
export function transformRoutes(routes) {
const result = [];
routes.forEach(route => {
const tmp = {
path: route.path,
component: loadComponent(route.component),
redirect: route.redirect,
name: route.name,
meta: route.meta
};
// 递归处理子路由
if (route.children && route.children.length > 0) {
tmp.children = transformRoutes(route.children);
}
result.push(tmp);
});
return result;
}
src/store/modules/permission.js(all模式版本)
import { defineStore } from 'pinia';
import { constantRoutes } from '@/router/constantRoutes';
import { transformRoutes } from '@/router/permission';
import { getRoutes } from '@/api/user';
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [],
addRoutes: [],
sidebarRoutes: []
}),
getters: {
getRoutes: (state) => state.routes,
getSidebarRoutes: (state) => state.sidebarRoutes
},
actions: {
/**
* 从后端获取用户的路由配置
* @returns {Promise<Array>}
*/
async generateRoutes() {
try {
// 调用后端接口获取路由配置
const { data } = await getRoutes();
// 转换路由配置
const accessedRoutes = transformRoutes(data);
// 保存动态路由
this.addRoutes = accessedRoutes;
// 合并公开路由和动态路由
this.routes = constantRoutes.concat(accessedRoutes);
// 提取用于侧边栏的路由
this.sidebarRoutes = accessedRoutes.filter(route => !route.meta?.hidden);
return accessedRoutes;
} catch (error) {
throw error;
}
},
/**
* 重置路由
*/
resetRoutes() {
this.routes = [];
this.addRoutes = [];
this.sidebarRoutes = [];
}
}
});
src/permission.js(all模式版本)
import router from './router';
import store from './store';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const whiteList = ['/login', '/404'];
router.beforeEach(async (to, from, next) => {
NProgress.start();
document.title = to.meta.title || 'Vue Admin Better';
const hasToken = store.getters.token;
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' });
NProgress.done();
} else {
const hasRoles = store.getters.roles && store.getters.roles.length > 0;
if (hasRoles) {
next();
} else {
try {
// 获取用户信息
const { roles } = await store.dispatch('user/getUserInfo');
// 从后端获取用户的路由配置
const accessRoutes = await store.dispatch('permission/generateRoutes');
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route);
});
next({ ...to, replace: true });
} catch (error) {
await store.dispatch('user/resetToken');
ElMessage.error(error.message || 'Has Error');
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
});
router.afterEach(() => {
NProgress.done();
});
按钮级权限控制
除了路由级和菜单级权限,还需要实现按钮级权限控制。
方案1:自定义指令v-permission
src/directives/permission/index.js
import { useUserStore } from '@/store/modules/user';
/**
* 检查用户是否有指定权限
* @param {Array|String} value - 权限标识或权限列表
* @returns {Boolean}
*/
function checkPermission(value) {
const userStore = useUserStore();
const { roles, permissions } = userStore;
// admin角色拥有所有权限
if (roles.includes('admin')) {
return true;
}
// 如果没有传入权限值,默认不允许
if (!value || value.length === 0) {
return false;
}
// 支持数组和字符串两种格式
const requiredPermissions = Array.isArray(value) ? value : [value];
// 检查用户是否拥有至少一个所需权限
return requiredPermissions.some(permission => {
return permissions.includes(permission);
});
}
/**
* v-permission指令
* 用法:
* <el-button v-permission="['user:create']">新增</el-button>
* <el-button v-permission="'user:delete'">删除</el-button>
*/
export default {
mounted(el, binding) {
const { value } = binding;
if (!checkPermission(value)) {
// 没有权限,移除元素
el.parentNode && el.parentNode.removeChild(el);
}
}
};
注册指令(main.js)
import { createApp } from 'vue';
import App from './App.vue';
import permission from './directives/permission';
const app = createApp(App);
// 注册自定义指令
app.directive('permission', permission);
app.mount('#app');
使用示例
<template>
<div class="user-list">
<!-- 只有拥有user:create权限的用户能看到 -->
<el-button
type="primary"
v-permission="['user:create']"
@click="handleCreate"
>
新增用户
</el-button>
<!-- 只有拥有user:update权限的用户能看到 -->
<el-button
type="warning"
v-permission="['user:update']"
@click="handleUpdate"
>
编辑
</el-button>
<!-- 只有拥有user:delete权限的用户能看到 -->
<el-button
type="danger"
v-permission="['user:delete']"
@click="handleDelete"
>
删除
</el-button>
<!-- 拥有任意一个权限就能看到 -->
<el-button
v-permission="['user:export', 'data:export']"
@click="handleExport"
>
导出
</el-button>
</div>
</template>
方案2:工具函数checkPermission
src/utils/permission.js
import { useUserStore } from '@/store/modules/user';
/**
* 检查用户是否有指定权限(用于模板中)
* @param {Array|String} value - 权限标识或权限列表
* @returns {Boolean}
*/
export function checkPermission(value) {
const userStore = useUserStore();
const { roles, permissions } = userStore;
// admin角色拥有所有权限
if (roles.includes('admin')) {
return true;
}
if (!value || value.length === 0) {
return false;
}
const requiredPermissions = Array.isArray(value) ? value : [value];
return requiredPermissions.some(permission => {
return permissions.includes(permission);
});
}
使用示例
<template>
<div class="user-list">
<!-- 在模板中使用 -->
<el-button
v-if="checkPermission(['user:create'])"
type="primary"
@click="handleCreate"
>
新增用户
</el-button>
<!-- 结合多个条件 -->
<el-dropdown v-if="checkPermission(['user:update', 'user:delete'])">
<el-button>
更多操作
<el-icon><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="checkPermission('user:update')">
编辑
</el-dropdown-item>
<el-dropdown-item v-if="checkPermission('user:delete')">
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { checkPermission } from '@/utils/permission';
function handleCreate() {
// ...
}
function handleUpdate() {
// ...
}
function handleDelete() {
// ...
}
</script>
路由守卫中的权限校验
高级路由守卫(支持权限变更检测)
import router from './router';
import store from './store';
import { ElMessage, ElMessageBox } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const whiteList = ['/login', '/404'];
// 标记是否正在刷新权限
let isRefreshing = false;
router.beforeEach(async (to, from, next) => {
NProgress.start();
document.title = to.meta.title || 'Vue Admin Better';
const hasToken = store.getters.token;
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' });
NProgress.done();
} else {
const hasRoles = store.getters.roles && store.getters.roles.length > 0;
if (hasRoles) {
// 已获取角色,进行权限校验
const hasPermission = validatePermission(to);
if (hasPermission) {
next();
} else {
// 没有权限,提示用户
ElMessage.error('您没有权限访问该页面');
next('/403'); // 跳转到403页面
NProgress.done();
}
} else {
try {
// 防止重复刷新
if (isRefreshing) {
next();
return;
}
isRefreshing = true;
// 获取用户信息
const { roles } = await store.dispatch('user/getUserInfo');
// 生成路由表
const accessRoutes = await store.dispatch('permission/generateRoutes', roles);
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route);
});
isRefreshing = false;
// 重新导航
next({ ...to, replace: true });
} catch (error) {
isRefreshing = false;
// 清除token并跳转登录页
await store.dispatch('user/resetToken');
ElMessage.error(error.message || 'Has Error');
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next(`/login?redirect=${to.path}`);
NProgress.done();
}
}
});
/**
* 验证用户是否有权限访问目标路由
* @param {Route} to - 目标路由
* @returns {Boolean}
*/
function validatePermission(to) {
const userStore = useUserStore();
const { roles } = userStore;
// admin角色拥有所有权限
if (roles.includes('admin')) {
return true;
}
// 检查路由配置的roles
if (to.matched && to.matched.length > 0) {
for (const record of to.matched) {
if (record.meta && record.meta.roles) {
const hasRole = record.meta.roles.some(role => roles.includes(role));
if (!hasRole) {
return false;
}
}
}
}
return true;
}
router.afterEach(() => {
NProgress.done();
});
框架集成
Vue Router集成
动态路由的最佳实践
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './constantRoutes';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
// 只注册公开路由,动态路由在登录后添加
routes: constantRoutes
});
export default router;
Pinia状态管理集成
完整的权限Store
import { defineStore } from 'pinia';
import { useUserStore } from './user';
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [],
addRoutes: [],
sidebarRoutes: [],
// 缓存已访问的页面
visitedViews: [],
// 缓存已打开的标签页
cachedViews: []
}),
getters: {
// 获取完整路由表
getRoutes: (state) => state.routes,
// 获取侧边栏路由
getSidebarRoutes: (state) => state.sidebarRoutes,
// 获取已访问的视图
getVisitedViews: (state) => state.visitedViews,
// 获取缓存的视图
getCachedViews: (state) => state.cachedViews
},
actions: {
/**
* 生成路由
*/
async generateRoutes(roles) {
// ... 前面已实现
},
/**
* 添加访问记录
*/
addView(view) {
// 避免重复添加
if (this.visitedViews.some(v => v.path === view.path)) {
return;
}
this.visitedViews.push({
name: view.name,
title: view.meta.title || 'no-name',
path: view.path,
fullPath: view.fullPath
});
// 添加到缓存
if (view.meta.keepAlive) {
this.cachedViews.push(view.name);
}
},
/**
* 删除访问记录
*/
delView(view) {
const index = this.visitedViews.findIndex(v => v.path === view.path);
if (index > -1) {
this.visitedViews.splice(index, 1);
}
const cacheIndex = this.cachedViews.indexOf(view.name);
if (cacheIndex > -1) {
this.cachedViews.splice(cacheIndex, 1);
}
},
/**
* 清空所有访问记录
*/
delAllViews() {
this.visitedViews = [];
this.cachedViews = [];
},
/**
* 重置
*/
resetRoutes() {
this.routes = [];
this.addRoutes = [];
this.sidebarRoutes = [];
this.visitedViews = [];
this.cachedViews = [];
}
}
});
自定义指令v-permission
增强版指令(支持禁用而非隐藏)
export default {
mounted(el, binding) {
const { value, modifiers } = binding;
if (!checkPermission(value)) {
if (modifiers.disabled) {
// 禁用模式:保留元素但禁用交互
el.disabled = true;
el.classList.add('is-disabled');
el.style.cursor = 'not-allowed';
el.style.opacity = '0.5';
// 阻止点击事件
el.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
}, true);
} else {
// 隐藏模式:移除元素
el.parentNode && el.parentNode.removeChild(el);
}
}
},
updated(el, binding) {
// 权限变化时重新检查
if (binding.value !== binding.oldValue) {
this.mounted(el, binding);
}
}
};
使用示例
<template>
<!-- 隐藏模式(默认) -->
<el-button v-permission="['user:delete']">删除</el-button>
<!-- 禁用模式 -->
<el-button v-permission.disabled="['user:delete']">删除</el-button>
</template>
TypeScript类型安全封装
类型定义
// types/permission.ts
/**
* 角色类型
*/
export type Role = 'admin' | 'editor' | 'user';
/**
* 权限标识
*/
export type Permission =
| 'user:view' | 'user:create' | 'user:update' | 'user:delete'
| 'role:view' | 'role:create' | 'role:update' | 'role:delete'
| 'menu:view' | 'menu:create' | 'menu:update' | 'menu:delete'
| 'data:view' | 'data:export';
/**
* 路由元数据
*/
export interface RouteMeta {
title?: string;
icon?: string;
roles?: Role[];
permissions?: Permission[];
hidden?: boolean;
keepAlive?: boolean;
}
/**
* 路由配置
*/
export interface AppRouteRecordRaw {
path: string;
component?: any;
redirect?: string;
name?: string;
meta?: RouteMeta;
children?: AppRouteRecordRaw[];
hidden?: boolean;
}
/**
* 用户信息
*/
export interface UserInfo {
id: number;
username: string;
roles: Role[];
permissions: Permission[];
name: string;
avatar: string;
}
类型安全的权限检查
// utils/permission.ts
import { useUserStore } from '@/store/modules/user';
import type { Role, Permission } from '@/types/permission';
/**
* 检查用户是否有指定角色
*/
export function hasRole(role: Role | Role[]): boolean {
const userStore = useUserStore();
const { roles } = userStore;
if (roles.includes('admin')) {
return true;
}
const requiredRoles = Array.isArray(role) ? role : [role];
return requiredRoles.some(r => roles.includes(r));
}
/**
* 检查用户是否有指定权限
*/
export function hasPermission(permission: Permission | Permission[]): boolean {
const userStore = useUserStore();
const { roles, permissions } = userStore;
if (roles.includes('admin')) {
return true;
}
const requiredPermissions = Array.isArray(permission) ? permission : [permission];
return requiredPermissions.some(p => permissions.includes(p));
}
/**
* 检查用户是否有任意一个权限
*/
export function hasAnyPermission(permissions: Permission[]): boolean {
return hasPermission(permissions);
}
/**
* 检查用户是否有所有权限
*/
export function hasAllPermissions(permissions: Permission[]): boolean {
const userStore = useUserStore();
const { roles, permissions: userPermissions } = userStore;
if (roles.includes('admin')) {
return true;
}
return permissions.every(p => userPermissions.includes(p));
}
测试策略
单元测试
权限过滤逻辑测试
import { describe, it, expect } from 'vitest';
import { filterAsyncRoutes, hasPermission } from '@/router/permission';
import { asyncRoutes } from '@/router/constantRoutes';
describe('Permission Filter', () => {
it('应该允许admin访问所有路由', () => {
const roles = ['admin'];
const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
// admin应该能看到所有路由
expect(filteredRoutes.length).toBeGreaterThan(0);
});
it('应该拒绝普通用户访问系统管理', () => {
const roles = ['user'];
const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
// 普通用户不应该看到系统管理菜单
const systemRoute = filteredRoutes.find(r => r.path === '/system');
expect(systemRoute).toBeUndefined();
});
it('应该允许编辑访问数据管理', () => {
const roles = ['editor'];
const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
// 编辑应该能看到数据管理
const dataRoute = filteredRoutes.find(r => r.path === '/data');
expect(dataRoute).toBeDefined();
});
it('应该正确处理嵌套子路由', () => {
const roles = ['editor'];
const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
const dataRoute = filteredRoutes.find(r => r.path === '/data');
expect(dataRoute.children).toBeDefined();
// 编辑不能看到数据导出
const exportChild = dataRoute.children.find(c => c.path === 'export');
expect(exportChild).toBeUndefined();
});
});
按钮权限测试
import { describe, it, expect, vi } from 'vitest';
import { checkPermission } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';
vi.mock('@/store/modules/user');
describe('Button Permission', () => {
it('admin应该拥有所有权限', () => {
useUserStore.mockReturnValue({
roles: ['admin'],
permissions: []
});
expect(checkPermission(['user:delete'])).toBe(true);
});
it('普通用户只能访问授权的按钮', () => {
useUserStore.mockReturnValue({
roles: ['user'],
permissions: ['user:view', 'data:view']
});
expect(checkPermission('user:view')).toBe(true);
expect(checkPermission('user:delete')).toBe(false);
});
it('应该支持多个权限OR逻辑', () => {
useUserStore.mockReturnValue({
roles: ['user'],
permissions: ['user:view', 'data:export']
});
// 拥有其中一个权限即可
expect(checkPermission(['user:delete', 'data:export'])).toBe(true);
});
});
集成测试
完整登录流程测试
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import App from '@/App.vue';
import { useUserStore } from '@/store/modules/user';
import { usePermissionStore } from '@/store/modules/permission';
describe('Permission Integration', () => {
let wrapper;
let router;
let pinia;
beforeEach(() => {
pinia = createPinia();
router = createRouter({
history: createWebHistory(),
routes: []
});
wrapper = mount(App, {
global: {
plugins: [router, pinia]
}
});
});
it('登录后应该动态添加路由', async () => {
const userStore = useUserStore(pinia);
const permissionStore = usePermissionStore(pinia);
// 模拟登录
await userStore.login({ username: 'admin', password: '123456' });
await userStore.getUserInfo();
await permissionStore.generateRoutes(userStore.roles);
// 检查路由是否已添加
expect(permissionStore.addRoutes.length).toBeGreaterThan(0);
expect(router.getRoutes().length).toBeGreaterThan(2);
});
it('登出后应该移除动态路由', async () => {
const userStore = useUserStore(pinia);
const permissionStore = usePermissionStore(pinia);
// 先登录
await userStore.login({ username: 'admin', password: '123456' });
await userStore.getUserInfo();
await permissionStore.generateRoutes(userStore.roles);
// 再登出
await userStore.logout();
permissionStore.resetRoutes();
// 检查路由是否已移除
expect(permissionStore.addRoutes.length).toBe(0);
expect(router.getRoutes().length).toBeLessThan(5);
});
});
边界条件测试
异常场景测试
describe('Edge Cases', () => {
it('应该处理空角色数组', () => {
const roles = [];
const filteredRoutes = filterAsyncRoutes(asyncRoutes, roles);
expect(filteredRoutes.length).toBe(0);
});
it('应该处理undefined角色', () => {
const roles = undefined;
expect(() => {
filterAsyncRoutes(asyncRoutes, roles);
}).toThrow();
});
it('应该处理不存在的路由配置', () => {
const invalidRoutes = [
{
path: '/test',
component: null,
children: undefined
}
];
expect(() => {
transformRoutes(invalidRoutes);
}).not.toThrow();
});
it('应该处理循环嵌套的路由', () => {
const circularRoutes = [
{
path: '/parent',
children: [
{
path: 'child',
children: [
{
path: 'grandchild',
children: [] // 空子路由
}
]
}
]
}
];
expect(() => {
transformRoutes(circularRoutes);
}).not.toThrow();
});
});
最容易踩的5个坑
坑1:忘记在路由守卫中调用next()
错误示例
router.beforeEach(async (to, from, next) => {
if (hasToken) {
if (hasRoles) {
// ❌ 忘记调用next()
console.log('已登录且有角色');
} else {
const { roles } = await getUserInfo();
const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
next({ ...to, replace: true });
}
} else {
next('/login');
}
});
后果:路由守卫挂起,页面无法加载
正确做法
router.beforeEach(async (to, from, next) => {
if (hasToken) {
if (hasRoles) {
next(); // ✅ 必须调用next()
} else {
try {
const { roles } = await getUserInfo();
const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
next({ ...to, replace: true });
} catch (error) {
await resetToken();
next('/login'); // ✅ 错误时也要调用next()
}
}
} else {
next('/login');
}
});
坑2:动态路由添加后未重新导航
错误示例
const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
// ❌ 没有重新导航,当前路由仍然是404
next();
后果:添加路由后,用户访问的URL找不到对应路由
正确做法
const accessRoutes = generateRoutes(roles);
accessRoutes.forEach(route => router.addRoute(route));
next({ ...to, replace: true }); // ✅ 重新导航到目标路由
坑3:admin角色未特殊处理
错误示例
function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role));
}
return true;
}
后果:admin角色也需要在每个路由中配置,失去管理员的意义
正确做法
function hasPermission(roles, route) {
// ✅ admin角色拥有所有权限
if (roles.includes('admin')) {
return true;
}
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role));
}
return true;
}
坑4:按钮权限指令未处理权限变化
错误示例
export default {
mounted(el, binding) {
if (!checkPermission(binding.value)) {
el.parentNode.removeChild(el);
}
}
// ❌ 没有updated钩子,权限变化时不更新
};
后果:用户角色变更后,按钮权限不会实时更新
正确做法
export default {
mounted(el, binding) {
if (!checkPermission(binding.value)) {
el.parentNode.removeChild(el);
}
},
updated(el, binding) {
// ✅ 权限变化时重新检查
if (binding.value !== binding.oldValue) {
this.mounted(el, binding);
}
}
};
坑5:通配符路由过早注册
错误示例
// ❌ 在初始化时就注册所有路由(包括通配符)
const router = createRouter({
routes: [
{ path: '/login', component: Login },
{ path: '/dashboard', component: Dashboard },
{ path: '/:pathMatch(.*)*', redirect: '/404' } // 通配符
]
});
后果:通配符路由会匹配所有路径,导致动态路由失效
正确做法
// ✅ 初始化时只注册公开路由
const router = createRouter({
routes: constantRoutes // 不包含通配符
});
// 登录后再添加动态路由和通配符
accessRoutes.forEach(route => {
router.addRoute(route);
});
面试高频考点
考点1:intelligence模式和all模式的区别
回答要点:
- 权限来源:前端维护 vs 后端返回
- 安全性:较低(前端可篡改)vs 较高(后端控制)
- 灵活性:前端独立开发 vs 需后端配合
- 适用场景:内部系统 vs 金融/电商后台
- 性能:无需请求 vs 需额外请求一次
考点2:如何实现动态路由?
回答要点:
- 路由表设计:区分公开路由和动态路由
- 登录后流程:获取角色 → 生成路由 → 动态添加 → 重新导航
- 路由守卫:beforeEach中校验token和角色
- 持久化:localStorage缓存,刷新时恢复
- 登出清理:移除动态路由,重置状态
考点3:按钮级权限的实现方式
回答要点:
- 自定义指令:v-permission,挂载时检查权限
- 工具函数:checkPermission,用于模板中的v-if
- 权限数据:用户信息中包含permissions数组
- admin特权:admin角色默认拥有所有权限
- 更新机制:权限变化时通过updated钩子重新检查
考点4:如何处理权限变更?
回答要点:
- 主动刷新:提供"刷新权限"按钮
- 被动推送:WebSocket推送权限变更通知
- 重新登录:最简单的方式,退出后重新登录
- 路由重载:清空动态路由,重新获取并添加
- 页面刷新:location.reload()强制刷新
考点5:路由守卫的执行顺序
回答要点:
- 全局前置守卫:router.beforeEach
- 路由独享守卫:beforeEnter(路由配置中)
- 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
- 全局解析守卫:router.beforeResolve
- 全局后置钩子:router.afterEach(不能改变导航)
总结与扩展
核心经验总结
-
选择合适的权限模式
- 内部系统用intelligence模式(开发快)
- 高安全要求用all模式(更安全)
- 可以混合使用(大部分前端控制,敏感页面后端控制)
-
多层防护不可少
- 路由级:路由守卫拦截
- 菜单级:过滤不可见菜单
- 按钮级:隐藏或禁用操作
- API级:后端最终校验
-
动态路由要注意细节
- 添加后必须重新导航
- 通配符路由最后添加
- 登出时要清理动态路由
- 刷新页面要恢复路由
-
权限缓存要合理
- localStorage持久化token和角色
- Pinia/Vuex运行时缓存
- 提供强制刷新接口
- 可选WebSocket推送变更
-
测试覆盖关键场景
- 正常登录流程
- 权限不足拦截
- 权限变更刷新
- 登出清理路由
扩展阅读
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)