全面解析后台管理系统前端如何实现权限控制 ( 全流程实战 )

1,631 阅读12分钟

一、简介

本文将深入解析后台管理系统中的按钮级别权限控制实现流程。主要内容包括: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",},
                // 其他动态路由
            ]
        },
        {
            // 其他菜单
        }
    ]
}

方案分析

该方案在灵活性与安全性之间取得了较好的平衡。前端静态路由提供了基础结构,后端返回的动态路由则根据用户的权限进行个性化定制。相比方案二,安全性没有那么高,理论上可以通过接口返回信息看到所有菜单信息,但是对于后端缓存友好。

方案选择

对于开源项目而言,由于用户角色、使用场景和权限需求可能有很大的不确定性,选择一个灵活且易扩展的权限管理方案至关重要。考虑到开源项目的这些特点,方案三提供了良好的折中选择。

在开源项目中,由于用户群体的不确定性、需求的多样性和灵活性要求,方案三是最佳选择。它在市场上也更为流行,能够很好地平衡安全性、灵活性和性能,适应不同开发者和使用者的需求。因此,我们建议在开源项目中优先采用方案三,以保证系统的灵活扩展和长期可维护性。

三、图解方案三权限控制流程

注意

  • 用户信息和菜单信息为两个接口
  • 会根据角色动态加载菜单

image.png

四、开始动手编码

1. 静态路由动态路由代码编写

参考结构

  • index.ts : 注册 vue-router
  • core :
    • AsyncRoutes.ts : 动态路由操作
    • ConstantRoutes.ts : 静态路由操作
    • RouteGuards.ts : 路由守卫

image.png

静态路由定义

静态路由用于存储基础的路由信息,通常包括不需要权限的页面,如登录页、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 中会存储菜单对应角色信息。
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. 渲染菜单信息

参考结构

image.png

了解核心组件

  • 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 子组件,并处理递归逻辑

核心代码讲解

  1. isLeafRoute(childrenRoute)  判断叶子节点:

    • 用来判断一个菜单项是否为叶子节点。叶子节点是没有子菜单或只有一个有效子菜单的节点。
    • 如果是叶子节点,则只渲染唯一的子菜单;否则渲染整个 el-sub-menu,显示多个子菜单项。
  2. resolvePath(routePath)  解析路径:

    • 用于将相对路径与父路径拼接成绝对路径。
    • 根据传入的 routePath 判断是否是有效的 URL,如果是相对路径则与 basePath 拼接。

核心逻辑讲解

  1. 菜单结构的动态生成

    • <el-menu-item>  和  <el-sub-menu> :根据菜单项 menuItem 的结构,决定是渲染单个菜单项还是渲染包含子菜单的父菜单。使用递归渲染子菜单。

      • 如果路由项是叶子节点,则渲染 el-menu-item
      • 如果有子路由,则渲染 el-sub-menu,并递归处理子项。
  2. 模板部分

    • 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-right20px
:deep(.el-sub-menu__title) {
  padding-right: 0;
}
</style>
对于 SidebarMenuItem 中 showSingleChildren 特殊讲解

image.png

上图这两个菜单其实都是 父菜单 -> 层级一 -> 层级二 , 但是层级一下面只有一个菜单层级二,可以通过 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-ifv-show的逻辑一样即可

参考结构

image.png

自定义权限指令编写

在不具备权限的时候,删除该元素

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 用户(分配所有菜单、按钮权限)

image.png

登录 USER_EXPERIENCE 用户(分配所有菜单、未分配任何按钮权限)

无法显示按钮信息

image.png

登录 HOME 用户(未分配任何菜单、未分配任何按钮权限)

可以显示静态路由中的菜单

image.png

访问不属于 HOME 用户的页面

直接跳转到404

image.png

六、结束语 & 源码

本文介绍了多种权限控制方案,旨在为大家提供更多的思路,而不仅限于实战中的单一方案。如果有任何未解释清楚或难以理解的地方,欢迎大家从 Gitee 入群与我交流,我非常乐意与大家分享和探讨,甚至可以免费安排会议详细讲解。希望通过交流结交更多志同道合的朋友!

源码地址 : gitee.com/fateyifei/y… ( 注: yf-vue-admin 为 vue 版本的前端 )