一、简介
本文将深入解析后台管理系统中的按钮级别权限控制实现流程。主要内容包括:RBAC权限控制的前后端实现方案、动态与静态路由的编写与管理、系统菜单的渲染机制以及自定义权限指令的实现。通过本教程,您将全面掌握如何在后台系统中灵活、高效地管理权限,确保系统安全和用户体验的优化。
二、我们先确定前后端方案实现方案再进行编码
在实现 RBAC(基于角色的访问控制)权限控制前,我们需要确定前后端方案。不同方案有不同的优势,适用不同项目需求。
方案一:前端维护页面与角色映射
在此方案中,前端负责维护完整的角色与页面的映射关系,后端仅提供用户的角色信息。前端根据用户的角色对权限进行判断并渲染相应的页面内容。
前端维护的路由信息示例
{
path: RouteConstant.PAGE_PATH,
name: "Page",
component: Page,
meta: {
title: "系统管理", // 页面标题
breadcrumb: true, // 是否在面包屑中展示
hidden: false, // 是否隐藏页面
icon: "system", // 图标
keepAlive: false, // 是否启用 keep-alive 缓存
roles: ["USER_EXPERIENCE", "ADMIN"], // 允许访问的角色
showSingleChildren: false, // 单个子菜单是否折叠显示
},
children: [
// 子路由信息
]
}
用户登录后的角色数据结构
data : {
roles : ["admin", "test"],
permission : ["system:user:list", "system:user:save", "..."]
}
方案分析
此方案适用于开发周期较短、角色和页面较少的场景。在这种情况下,按钮级别的权限控制通常不那么重要,可以简化流程,减少后端的工作量。前端通过维护角色和页面的映射表,可以更灵活、高效地进行权限管理与页面渲染。
方案二:前端基于后端返回的权限数据动态渲染
在此方案中,前端不再维护完整的角色与页面映射表。前端只存储必要的静态路由信息,而角色对应的动态菜单及路由由后端直接返回。前端根据这些数据进行页面的渲染,不需要自行判断用户角色。
前端维护的路由信息
前端无需维护全部路由信息,仅需存储一部分静态路由,如登录页等公共页面的路由。动态路由完全依赖后端返回的数据。
用户登录后后端返回的数据结构
data : {
permission : ["system:user:list", "system:user:save", "..."],
menus : [{
component: "组件路径",
meta: {
title: "系统管理",
icon: "system",
hidden: false,
keepAlive: false,
breadcrumb: true,
// 其他元数据
},
name: "/system",
path: "/system",
redirect: "/system/user"
// ...
children: [
{path: "menu", component: "system/menu/index", name: "Menu", …},
// 其他子路由
]},{
// ...
}
]
}
方案分析
此方案适用于安全性要求较高的场景,因所有权限判断和路由数据均由后端控制,前端无需自行管理角色信息,从而降低了前端的安全风险。然而,方案对后端的负担较大,增加了后端的开发复杂性,且动态路由的处理使缓存难以优化。
方案三:前后端混合权限控制
与方案二类型,但是需要后端返回角色信息,前端进行二次判断。
前端维护静态路由信息
{
path: RouteConstant.PAGE_PATH,
name: "Page",
component: Page,
meta: {
title: "系统管理", // 页面标题
breadcrumb: true, // 是否在面包屑中展示
hidden: false, // 是否隐藏页面
icon: "system", // 图标
keepAlive: false, // 是否启用 keep-alive 缓存
showSingleChildren: false // 单个子菜单是否折叠显示
},
children: [
// 子路由信息
]
}
后端返回动态路由信息
data: {
permission: ["system:user:list", "system:user:save", "..."],
menus: [
{
component: "Layout",
meta: {
title: "系统管理",
breadcrumb: true,
hidden: false,
icon: "system",
keepAlive: false,
roles: ["USER_EXPERIENCE", "ADMIN"], // 角色信息
showSingleChildren: false
},
name: "/system",
path: "/system",
redirect: "/system/user",
children: [
{path: "menu", component: "system/menu/index", name: "Menu", …},
// 其他动态路由
]
},
{
// 其他菜单
}
]
}
方案分析
该方案在灵活性与安全性之间取得了较好的平衡。前端静态路由提供了基础结构,后端返回的动态路由则根据用户的权限进行个性化定制。相比方案二,安全性没有那么高,理论上可以通过接口返回信息看到所有菜单信息,但是对于后端缓存友好。
方案选择
对于开源项目而言,由于用户角色、使用场景和权限需求可能有很大的不确定性,选择一个灵活且易扩展的权限管理方案至关重要。考虑到开源项目的这些特点,方案三提供了良好的折中选择。
在开源项目中,由于用户群体的不确定性、需求的多样性和灵活性要求,方案三是最佳选择。它在市场上也更为流行,能够很好地平衡安全性、灵活性和性能,适应不同开发者和使用者的需求。因此,我们建议在开源项目中优先采用方案三,以保证系统的灵活扩展和长期可维护性。
三、图解方案三权限控制流程
注意
- 用户信息和菜单信息为两个接口
- 会根据角色动态加载菜单
四、开始动手编码
1. 静态路由动态路由代码编写
参考结构
- index.ts : 注册 vue-router
- core :
- AsyncRoutes.ts : 动态路由操作
- ConstantRoutes.ts : 静态路由操作
- RouteGuards.ts : 路由守卫
静态路由定义
静态路由用于存储基础的路由信息,通常包括不需要权限的页面,如登录页、404页等。详细代码可以参考源码:源码向导。
封装动态路由中的操作逻辑
AsyncRoutes.ts
: 主要对外提供一些动态路由的操作逻辑
- 主要方法介绍
- filterAsyncRoutes : 递归过滤用户有权限的异步(动态)路由,根据用户的角色集合筛选出用户有权限访问的路由,并为路由指定正确的组件。
- hasPermission: 检查用户的角色是否有权限访问指定的路由。
- routeVo2RecordRaw :将接口返回的路由对象(
RouteVO
)转换为 Vue Router 路由配置(RouteRecordRaw
)。
- 主要代码介绍
- 代码27行 : modules[
../../views/${route.component}.vue
] => 后端返回的数据中route.component
为前端组件路径。例如 : 加载/view/doc/help/index.vue
页面, 后端应该返回组件信息为doc/help/index
这样才能加载对应组件信息。(这个在新增菜单的时候非常重要) - 代码57行 : route.meta.roles.includes(role); => 后端返回的数据中,
route.meta
中会存储菜单对应角色信息。
- 代码27行 : modules[
import {RouteRecordRaw} from "vue-router";
import {Layout, NotFound} from "@/router/core/ConstantRoutes";
import {RouteVO} from "@/api/system/menu/type";
const modules = import.meta.glob("../../views/**/**.vue");
/**
* 递归过滤有权限的异步(动态)路由
*
* @param routes 接口返回的异步(动态)路由
* @param roles 用户角色集合
* @returns 返回用户有权限的异步(动态)路由
*/
export function filterAsyncRoutes(routes: RouteVO[], roles: string[]) {
const asyncRoutes: RouteRecordRaw[] = [];
routes.forEach((route) => {
const tempRoute: RouteRecordRaw = {...route, component: Layout, children: []} as RouteRecordRaw; // ES6扩展运算符复制新对象
// 1. 未传入 name 则使用path作为 name
if (!route.name) {
tempRoute.name = route.path;
}
// 2. 判断是否有权限
if (hasPermission(roles, tempRoute)) {
// 2. 1 判断是否匹配到子路由
if (route.component !== 'Layout') {
const component = modules[`../../views/${route.component}.vue`];
if (component) {
tempRoute.component = component;
} else {
tempRoute.component = NotFound;
}
}
// 2. 2 判断是否含有子路由
if (route.children) {
tempRoute.children = filterAsyncRoutes(route.children, roles);
}
// 2. 3 添加到异步路由中
asyncRoutes.push(tempRoute);
}
});
return asyncRoutes;
}
/**
* 用户角色集合是否包含路由的 meta.roles 判断用户是否有权限
*
* @param roles 用户角色集合
* @param route 路由
* @returns
*/
export function hasPermission(roles: string[], route: RouteRecordRaw): boolean {
if (route.meta && route.meta.roles) {
return roles.some((role) => {
if (route.meta?.roles) {
return route.meta.roles.includes(role);
}
});
}
return false;
}
/**
* routeVo -> RouteRecordRaw
*/
export function routeVo2RecordRaw(routeVo: RouteVO): RouteRecordRaw {
const {path, component, redirect, name, meta, children} = routeVo;
const routeRecordRaw: RouteRecordRaw = {
path,
component: Layout,
redirect,
name,
meta: {
title: meta.title,
icon: meta.icon,
hidden: meta.hidden,
keepAlive: meta.keepAlive,
affix: meta.affix,
breadcrumb: meta.breadcrumb,
showSingleChildren: meta.showSingleChildren,
roles: meta.roles,
},
children: []
};
if (children) {
routeRecordRaw.children = children.map(child => routeVo2RecordRaw(child));
}
return routeRecordRaw;
}
编写路由守卫,并且要清楚路由守卫需要实现的功能
RouteGuards.ts
路由守卫在整个权限管理中起到了关键作用,负责控制页面的访问权限,主要逻辑包括:
- 用户登录时:判断用户是否有访问当前页面的权限,若无权限则进行重定向。
- 第一次进入页面或刷新页面时:检查并重新加载用户信息,同时加载动态路由(根据用户权限生成)。
- 用户未登录时:拦截访问请求,若请求页面在白名单内则放行,否则重定向到登录页。
NProgress.start()
// 1. 判断是否需要登录权限
const userStore = useUserStore();
// 2. 判断是否登录 (jwt 最小长度 57)
if (userStore.authInfo.accessToken && userStore.authInfo.accessToken.length >= 57) {
// 表示已登录
// 1. 判断是否访问登录页面
if (to.path === RouteConstant.LOGIN_PATH) {
// 1.1 如果已登录,跳转首页
next({path: RouteConstant.HOME_PATH});
NProgress.done();
} else {
// 1.2 判断是否需要 加载过动态路由 & 获取用户基础信息
if (userStore.roles && userStore.roles.length > 0) {
// 1.2.1 如果已加载过动态路由判断是否有匹配的路由
// 未匹配到任何路由,跳转404
if (to.matched.length === 0) {
/*
* 方式一 : 访问未知页面时,如果为系统已有的页面,则永远不会跳转到 NOT_FOUND_PATH
* 方式二 : 访问未知页面时,跳转到 NOT_FOUND_PATH
* */
// from.name ? next({ name: from.name }) : next(RouteConstant.NOT_FOUND_PATH);
next(RouteConstant.NOT_FOUND_PATH);
} else {
next();
}
} else {
// 获取用户信息、菜单信息
const {roles} = await userStore.getUserInfo();
const accessRoutes = await userStore.generateRoutes(roles);
// 动态添加路由信息
accessRoutes.forEach((route: RouteRecordRaw) => {
router.addRoute(route);
});
next({...to, replace: true});
}
}
} else {
// 表示未登录
// 1. 判断是否在白名单中
if (RouteConstant.GUARDS_WHITE_LIST.indexOf(to.path) !== -1) {
next()
} else {
next({path: '/login', query: {redirect: to.path, ...to.query}});
NProgress.done();
}
}
该代码为前置路由代码,详细代码可以参考源码:源码导航
2. 渲染菜单信息
参考结构
了解核心组件
- SidebarMenu : 使用
<el-menu> </el-menu>
包裹<SidebarMenuItem></SidebarMenuItem>
- SidebarMenuItem : 主要处理
<el-menu-item></el-menu-item>
和<el-sub-menu></el-sub-menu>
的递归逻辑
接下来我们来编写具体代码 :
编写 SidebarMenu 父组件
<template>
<el-menu
:default-active="currentRoute.path"
:collapse="systemStore.app.sidebarStatus === SidebarStatusEnum.CLOSED"
:unique-opened="false"
:collapse-transition="false"
:mode="systemStore.settings.layout === LayoutEnum.TOP ? 'horizontal' : 'vertical'"
>
<SidebarMenuItem
v-for="item in userStore.routes"
:key="item.path"
:base-path="item.path"
:menu-item="item"
></SidebarMenuItem>
</el-menu>
</template>
<script setup lang="ts">
// 数据
import {useSystemStore} from "@/store/modules/system";
import {LayoutEnum} from "@/enums/LayoutEnum";
import {SidebarStatusEnum} from "@/enums/SidebarStatusEnum";
import {useUserStore} from "@/store/modules/user";
const systemStore = useSystemStore();
const userStore = useUserStore();
const currentRoute = useRoute();
// 方法
// 生命周期
</script>
<style scoped lang="scss">
/* 样式 */
</style>
编写 SidebarMenuItem 子组件,并处理递归逻辑
核心代码讲解
-
isLeafRoute(childrenRoute)
判断叶子节点:- 用来判断一个菜单项是否为叶子节点。叶子节点是没有子菜单或只有一个有效子菜单的节点。
- 如果是叶子节点,则只渲染唯一的子菜单;否则渲染整个
el-sub-menu
,显示多个子菜单项。
-
resolvePath(routePath)
解析路径:- 用于将相对路径与父路径拼接成绝对路径。
- 根据传入的
routePath
判断是否是有效的 URL,如果是相对路径则与basePath
拼接。
核心逻辑讲解
-
菜单结构的动态生成
-
<el-menu-item>
和<el-sub-menu>
:根据菜单项menuItem
的结构,决定是渲染单个菜单项还是渲染包含子菜单的父菜单。使用递归渲染子菜单。- 如果路由项是叶子节点,则渲染
el-menu-item
。 - 如果有子路由,则渲染
el-sub-menu
,并递归处理子项。
- 如果路由项是叶子节点,则渲染
-
-
模板部分:
v-if="isLeafRoute(menuItem.children!) && !menuItem.meta!.showSingleChildren"
:这一部分检查路由是否有子路由,并决定是显示单一子菜单项还是整个子菜单。v-for
:递归渲染子菜单项时,调用SidebarMenuItem
组件。
<template>
<!-- 显示未隐藏菜单 -->
<template v-if="!menuItem.meta || !menuItem.meta.hidden">
<!-- 显示具有单个子路由的菜单项或没有子路由的父路由 -->
<template
v-if="isLeafRoute(menuItem.children!) && !menuItem.meta!.showSingleChildren"
>
<AppLink v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
<el-menu-item
:index="resolvePath(onlyOneChild.path)"
>
<SystemIcon
:icon="onlyOneChild.meta.icon"
/>
<template #title>
<span>{{ onlyOneChild.meta.title }}</span>
</template>
</el-menu-item>
</AppLink>
</template>
<!-- 显示具有多个子路由的父菜单项 -->
<el-sub-menu v-else :index="resolvePath(menuItem.path)">
<template #title>
<SystemIcon :icon="menuItem.meta && menuItem.meta.icon"/>
<span>{{ menuItem.meta?.title }}</span>
</template>
<SidebarMenuItem
v-for="child in menuItem.children"
:key="child.path"
:menu-item="child"
:base-path="resolvePath(child.path)"
/>
</el-sub-menu>
</template>
</template>
<script setup lang="ts">
// 数据
import {RouteRecordRaw} from "vue-router";
import {validateURL} from "@/utils/validation";
import {Ref} from "vue";
const props = withDefaults(defineProps<{
menuItem: RouteRecordRaw,
basePath: string
}>(), {});
const onlyOneChild: Ref<RouteRecordRaw> = ref<RouteRecordRaw>({...props.menuItem, path: '', children: []});
// 方法
/**
* 判断是否是叶子节点
* 判断条件 : 无子节点或只有一个子节点,表示为叶子节点
* @param childrenRoute 子节点
*/
function isLeafRoute(childrenRoute: RouteRecordRaw[]): boolean {
// !childrenRoute 表示传入 undefined
if (!childrenRoute || childrenRoute.length === 0) {
return true;
}
// 过滤不展示的子节点
const filterRoute = childrenRoute.filter((item) => !item.meta || !item.meta.hidden);
// 判断子节点的子节点是否只有一个
const filterRouteChildren = filterRoute.filter((item) => item.children && item.children.length > 0);
if (filterRouteChildren.length > 0) {
return false;
}
// 将 onlyOneChild 赋值为节点中的 [0]
onlyOneChild.value = {...filterRoute[0], children: []}
return filterRoute.length <= 1;
}
/**
* 解析路径
* @param routePath 路由路径
*/
function resolvePath(routePath: string): string {
if (validateURL(routePath)) {
return routePath;
}
if (validateURL(props.basePath)) {
return props.basePath;
}
// 完整路径(/system/user) = 父级路径(/system) + 路由路径(user)
if (routePath.startsWith('/')) {
return routePath;
}
if (routePath === "") {
return props.basePath.replace(//$/, '');
}
// 否则,将 basePath 与 routePath 拼接成绝对路径
return props.basePath.replace(//$/, '') + '/' + routePath;
}
// 生命周期
</script>
<style scoped lang="scss">
/* 样式 */
// .el-sub-menu__title 会导致 padding-right 多 20px
:deep(.el-sub-menu__title) {
padding-right: 0;
}
</style>
对于 SidebarMenuItem 中 showSingleChildren
特殊讲解
上图这两个菜单其实都是 父菜单 -> 层级一 -> 层级二
, 但是层级一下面只有一个菜单层级二,可以通过 showSingleChildren
决定层级二是否直接替代层级一,当然你可以设置父菜单.showSingleChildren
来决定层级二是否直接代替父菜单。
3. 自定义权限指令 v-permissions
& v-roles
自定义权限比较简单,我们的使用方式大概是这样 :
<!-- 只有具有 'system:user:update' 才会显示编辑帖子 -->
<button v-permission="['system:user:update']">编辑帖子</button>
<!-- 只有具有 'system:user:list','system:user:delete' 其中之一才会显示仪表盘-->
<button v-permission="['system:user:list','system:user:delete']">查看仪表板</button>
<!-- 只有 ADMIN 才会显示编辑帖子 -->
<button v-role="['ADMIN']">编辑帖子</button>
<!-- 只有 ADMIN ,TEST 才会显示仪表盘-->
<button v-permission="[ADMIN , TEST]">查看仪表板</button>
对于如何隐藏,我们与v-if
或v-show
的逻辑一样即可
参考结构
自定义权限指令编写
在不具备权限的时候,删除该元素
import {Directive, DirectiveBinding} from "vue";
import {useUserStore} from "@/store/modules/user";
/**
* 按钮权限
*/
export const PermissionChecker: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const {permissions} = useUserStore();
// 「」按钮权限校验
const {value} = binding;
if (value) {
const hasPermission = permissions?.some((permission) => {
return value.includes(permission);
});
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error(
"need perms! Like v-has-perm="['sys:user:add','sys:user:edit']""
);
}
},
};
/**
* 角色权限
*/
export const RoleChecker: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const {roles} = useUserStore();
// 「」角色校验
const {value} = binding;
if (value) {
const hasRoles = roles.some((role) => {
return value.includes(role);
});
if (!hasRoles) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error(
"need perms! Like v-has-perm="['sys:user:add','sys:user:edit']""
);
}
},
};
在 Vue 中注册自定义指令
import type {App} from "vue";
import {PermissionChecker, RoleChecker} from "./permission";
// 全局注册 directive
export function setupDirective(app: App<Element>) {
// 使 v-permission 在所有组件中都可用
app.directive("permission", PermissionChecker);
// 使 v-role 在所有组件中都可用
app.directive("role", RoleChecker);
}
自定义权限指令使用特殊讲解
在项目中,权限信息通常与接口绑定在一起,因此在使用自定义权限指令时,如果直接写成 v-permission="['system:user:update']
这样的硬编码,后续维护和修改会变得困难。因此,我们推荐将这些权限常量集中管理,避免在代码中硬编码权限,提升灵活性和可维护性。以下是具体的示例代码优化方案:
<el-table-column v-permission="[DeptAPI.SAVE.permission, DeptAPI.UPDATE.permission, DeptAPI.DELETE.permission]">
<template #default="scope">
<el-button v-permission="[DeptAPI.SAVE.permission]">
新增子部门
</el-button>
<el-button v-permission="[DeptAPI.UPDATE.permission]">
编辑
</el-button>
<el-button v-permission="[DeptAPI.DELETE.permission]">
删除
</el-button>
</template>
</el-table-column>
具体如何统一管理这些接口的,可以参考 让后端开发赞不绝口的 API 封装技巧:用 Axios 实现高效前端请求管理
五、在线演示
登录 ADMIN 用户(分配所有菜单、按钮权限)
登录 USER_EXPERIENCE 用户(分配所有菜单、未分配任何按钮权限)
无法显示按钮信息
登录 HOME 用户(未分配任何菜单、未分配任何按钮权限)
可以显示静态路由中的菜单
访问不属于 HOME 用户的页面
直接跳转到404
六、结束语 & 源码
本文介绍了多种权限控制方案,旨在为大家提供更多的思路,而不仅限于实战中的单一方案。如果有任何未解释清楚或难以理解的地方,欢迎大家从 Gitee 入群与我交流,我非常乐意与大家分享和探讨,甚至可以免费安排会议详细讲解。希望通过交流结交更多志同道合的朋友!
源码地址 : gitee.com/fateyifei/y… ( 注: yf-vue-admin 为 vue 版本的前端 )