普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月29日首页

RBAC 权限系统实战(一):页面级访问控制全解析

作者 十五丶
2026年1月29日 18:56

前言

本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现

在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你

本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

权限模型有哪些?

主流的权限模型主要分为以下五种:

  • ACL模型:访问控制列表
  • DAC模型:自主访问控制
  • MAC模型:强制访问控制
  • ABAC模型:基于属性的访问控制
  • RBAC模型:基于角色的权限访问控制

这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds

如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的

为什么是 RBAC 权限模型?

好问题!我帮你问了下 AI

对比维度 ACL (访问控制列表) RBAC (基于角色) ABAC (基于属性)
核心逻辑 用户 ↔ 权限
直接点对点绑定,无中间层
用户 ↔ 角色 ↔ 权限
引入“角色”解耦,权限归于角色
属性 + 规则 = 权限
动态计算 (Who, When, Where)
优点 模型极简,开发速度快,适合初期 MVP 结构清晰,复用性高,符合企业组织架构,维护成本低 极度灵活,支持细粒度控制
(如:只能在工作日访问)
缺点 用户量大时维护工作呈指数级增长,极易出错 角色爆炸:若特例过多,可能导致定义成百上千个角色 开发复杂度极高,规则引擎难设计,有一定的性能消耗
适用场景 个人博客、小型内部工具 中大型后台系统、SaaS 平台 (行业标准) 银行风控、AWS IAM、国家安全级系统

总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡

RBAC 概念理解

RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制

模型有三要素:

  • 用户(User):系统主体,即操作系统的具体人员或账号
  • 角色(Role):角色是一组权限的集合,代表了用户在组织中的职能或身份
  • 权限(Permission):用户可以对系统资源进行的访问或操作能力

RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制

image.png

并且,它们之间的逻辑关系通常是多对多的:

用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)

角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)

主导权限控制的前端、后端方案

市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案

前端主导的权限方案

前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中

后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮

这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”

适合一些小型、简单系统

后端主导的权限方案

后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等

菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改

倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)

在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案

权限方案整体流程

在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:

image.png

后台系统中的 RBAC 权限实战

权限菜单类型定义

首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如:

import type { RouteMeta, RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';
import type { Component } from 'vue';
import type { DefineComponent } from 'vue';
import type { RouteType } from '#/type';

declare global {
  export interface CustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
    /**
     * 路由地址
     */
    path?: string;
    /**
     * 路由名称
     */
    name?: string;
    /**
     * 重定向路径
     */
    redirect?: RouteRecordRedirectOption;
    /**
     * 组件
     */
    component?: Component | DefineComponent | (() => Promise<unknown>);
    /**
     * 子路由信息
     */
    children?: CustomRouteRecordRaw[];
    /**
     * 路由类型
     */
    type?: RouteType;
    /**
     * 元信息
     */
    meta: {
      /**
       * 菜单标题
       */
      title: string;
      /**
       * 菜单图标
       */
      menuIcon?: string;
      /**
       * 排序
       */
      sort?: number;
      /**
       * 是否在侧边栏菜单中隐藏
       * @default false
       */
      hideMenu?: boolean;
      /**
       * 是否在面包屑中隐藏
       * @default false
       */
      hideBreadcrumb?: boolean;
      /**
       * 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
       * @default false
       */
      hideParentIfSingleChild?: boolean;
    };
  }

  /**
   * 后端返回的权限路由类型定义
   */
  export type PermissionRoute = Omit<CustomRouteRecordRaw, 'component' | 'children' | 'type'> & {
    /**
     * 路由ID
     */
    id?: number;
    /**
     * 路由父ID
     */
    parentId?: number;
    /**
     * 组件路径(后端返回时为字符串,前端处理后为组件)
     */
    component: string;
    /**
     * 子路由信息
     */
    children?: PermissionRoute[];
    /**
     * 路由类型
     */
    type: RouteType;
  };
}

router.d.ts 找到类型文件

以上面的类型定义为例,我们约定 PermissionRoute 类型是后端返回的权限路由类型:

我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的:

clean-admin ApiFox 文档在线地址

image.png

从登录页到路由守卫

权限方案的第一步,是登录并拿到用户信息

假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:

  1. 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
  2. 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
  3. 触发路由守卫拦截

image.png

account-login.vue 找到全部代码

基本 Vue Router 配置

登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router

在整个权限系统中,我们将路由数据分为两种:

  1. 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
  2. 动态路由:由后端接口返回的用户角色对应的菜单路由数据

静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可

Vue Router 配置:

import { createRouter, createWebHashHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import type { App } from 'vue';
import type { ImportGlobRoutes } from './typing';
import { extractRoutes } from './helpers';
import { afterEachGuard, beforeEachGuard } from './guards';

/** 静态路由 */
const staticRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/constant-routes/**/*.ts'], {
    eager: true,
  }),
);

/** 系统路由 */
const systemRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/system-routes/**/*.ts'], {
    eager: true,
  }),
);

const router = createRouter({
  history: createWebHashHistory(),
  routes: [...staticRoutes, ...systemRoutes] as RouteRecordRaw[],
  strict: true,
  scrollBehavior: () => ({ left: 0, top: 0 }),
});

beforeEachGuard(router);
afterEachGuard(router);

/** 初始化路由 */
function initRouter(app: App<Element>) {
  app.use(router);
}

export { router, initRouter, staticRoutes };

图中的静态路由和系统路由是同一类路由数据,即静态路由

这个配置文件可以在 router/index.ts 找到

这个基本的 Vue Router 配置,做了这么几件事:

  1. 导入 modules 文件夹下的静态路由进行注册
  2. 路由初始化配置 initRouter ,在 main.ts 中调用
  3. 注册全局前置守卫 beforeEach、全局后置守卫 afterEach

我们实现动态路由注册的逻辑就写在 beforeEach

值得一提的是,使用了 import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入

路由守卫与动态注册

路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫

重头戏在全局前置守卫 router.beforeEach 中实现,来看看我们做哪些事:

import { ROUTE_NAMES } from '../config';
import type { RouteRecordNameGeneric, RouteRecordRaw, Router } from 'vue-router';
import { getLocalAccessToken } from '@/utils/permission';
import { userService } from '@/services/api';
import { nprogress } from './helpers';
import { storeToRefs } from 'pinia';

/** 登录认证页面:账号登录页、短信登录页、二维码登录页、忘记密码页、注册页... */
const authPages: RouteRecordNameGeneric[] = [
  ROUTE_NAMES.AUTH,
  ROUTE_NAMES.ACCOUNT_LOGIN,
  ROUTE_NAMES.SMS_LOGIN,
  ROUTE_NAMES.QR_LOGIN,
  ROUTE_NAMES.FORGOT_PASSWORD,
  ROUTE_NAMES.REGISTER,
];

/** 页面白名单:不需要登录也能访问的页面 */
const pageWhiteList: RouteRecordNameGeneric[] = [...authPages];

export function beforeEachGuard(router: Router) {
  router.beforeEach(async (to) => {
    /** 进度条:开始 */
    nprogress.start();

    const { name: RouteName } = to;

    const userStore = useUserStore();
    const { getAccessToken, getRoutesAddStatus, registerRoutes } = storeToRefs(userStore);
    const { setRoutesAddStatus, setUserInfo, logout } = userStore;

    /** 访问令牌 */
    const accessToken = getAccessToken.value || getLocalAccessToken();

    // 1.用户未登录(无 Token)
    if (!accessToken) {
      const isWhitePage = pageWhiteList.includes(RouteName);
      // 1.1 未登录,如果访问的是白名单中的页面,直接放行
      if (isWhitePage) return true;

      nprogress.done();

      // 1.2 未登录又不在白名单,则拦截并重定向到登录页
      return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
    }

    // 如果已登录用户试图访问登录页,避免重复登录,要强制重定向到首页
    if (authPages.includes(RouteName)) {
      nprogress.done();
      return { name: ROUTE_NAMES.ROOT };
    }

    // 判断是否需要动态加载路由的操作
    if (!getRoutesAddStatus.value) {
      // isRoutesAdded 默认为 false(未持久化),在已经动态注册过时会设置为true,在页面刷新时会重置为 false
      try {
        // 1.拉取用户信息
        const userInfo = await userService.getUserInfo();

        // 2.将用户信息存入 Store
        setUserInfo(userInfo);

        // 3.动态注册路由,registerRoutes 是处理后的路由表
        registerRoutes.value.forEach((route) => {
          router.addRoute(route as unknown as RouteRecordRaw);
        });

        // 4.标记路由已添加
        setRoutesAddStatus(true);

        // 5.中断当前导航,重新进入守卫
        return { ...to, replace: true };
      } catch (error) {
        // 获取用户信息失败(如 Token 过期失效、网络异常)
        logout();
        nprogress.done();
        // 重定向回登录页,让用户重新登录
        return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
      }
    }

    return true;
  });
}

before-each-guard.ts 找到全部代码

上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:

  1. 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
  2. 拉取用户信息,动态注册路由

image.png

在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定

image.png

比如在 vue-clean-admin 中,返回的数据结构是这样的:

在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息

image.png

后端路由结构的转化

在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw

处理什么内容呢?

比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径

image.png

实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 generateRoutes 函数:

/**
 * 生成符合 Vue Router 定义的路由表
 * @param routes 未转化的路由数据
 * @returns 符合结构的路由表
 */
export function generateRoutes(routes: PermissionRoute[]): CustomRouteRecordRaw[] {
  if (!routes.length) return [];
  return routes.map((route) => {
    const { path, name, redirect, type, meta } = route;
    const baseRoute: Omit<CustomRouteRecordRaw, 'children'> = {
      path,
      name,
      redirect,
      type,
      component: loadComponent(route),
      meta: {
        ...meta,
        // 是否在侧边栏菜单中隐藏
        hideMenu: route.meta?.hideMenu || false,
        // 是否在面包屑中隐藏
        hideBreadcrumb: route.meta?.hideBreadcrumb || false,
        // 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
        hideParentIfSingleChild: route.meta?.hideParentIfSingleChild || false,
      },
    };

    // 是目录数据,设置重定向路径
    if (type === PermissionRouteTypeEnum.DIR) {
      baseRoute.redirect = redirect || getRedirectPath(route);
    }
    // 递归处理子路由
    const processedChildren =
      route.children && route.children.length ? generateRoutes(route.children) : undefined;

    return {
      ...baseRoute,
      ...(processedChildren ? { children: processedChildren } : {}),
    };
  });
}

经过 generateRoutes 处理的路由表,再 addRoute 到 Vue Router 中

侧边栏菜单的渲染

当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单

侧边栏菜单是拿 Element Plus 的 el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能

封装不难,就是拿处理后的路由表循环渲染 menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等

image.png

菜单组件的封装代码在 basic-menu 文件夹中

到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完

因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正

❌
❌