发布时间:2025-06-24 19:18:19  作者:北方职教升学中心  阅读量:890


基本概念

权限控制,最常见的基本上有 2 种

  • 基于 ACL的权限控制

  • 基于 RBAC的权限控制

这个两种到底有什么不同呢?

我们通过下图来分析一下

图片

ACL是基于 用户 -> 权限,直接为每个用户分配权限

RBAC基于 用户 -> 角色 -> 权限,以角色为媒介,来为每个用户分配权限

这样做的好处是,某个权限过于敏感时,想要将每个用户或者部分用户的权限去掉,就不需要每个用户的权限都操作一遍,只需要删除对应角色的权限即可

那在实际的开发中 RBAC是最常用的权限控制方案,就前端而言,RBAC主要如何实现的呢?

主要就两个部分

  • 页面权限受控

  • 按钮权限受控

下面我们就来实现这两个部分

  • 页面权限

  • 按钮权限

页面的访问,我们都是需要配置路由表的,根据配置路由表的路径来访问页面

那么,我们控制了路由表,不就能控制页面的访问了吗?

实现思路

  • 前端根据不同用户信息,从后端获取该用户所拥有权限的路由表

  • 前端动态创建路由表

基本环境:

创建项目

 npm install -g @vue/cli vue --version # @vue/cli 5.0.8 vue create vue-router-dome

图片

打开项目,npm run serve运行一下

代码初始化,删除不必要的一些文件

图片

我们创建几个新文件夹

图片

写下基本的页面

图片

 ​​​​​​

<!-- home.vue --><template><div>主页</div></template>
<!-- menu.vue --><template><div>菜单管理</div></template>
<!-- user.vue --><template><div>用户管理</div></template>

写下路由配置

图片

 // remaining.tsimport Layout from '@/layout/index.vue'const remainingRouter: AppRouteRecordRaw[] = [{path: '/remaining',component: Layout,redirect: 'home',children: [{path: '/remaining/home',component: () => import('@/views/home.vue'),name: '首页',meta: {},}],name: '主页管理',meta: undefined},]export default remainingRouter

remaining主要为了存放一些公共路由,没有权限页可以访问,比如登录页、 本地路由 + 后端传过来的路由 = 菜单路由

按钮权限

根据不同用户,后端传过来每个按钮的按钮权限字符串,前端根据自定义指令,判断该按钮权限字符串是否存在 从而显示或者隐藏

扩展

一些特殊情况下,自定义指令隐藏无法满足我们想要的效果,我们可以定义一个公共函数检测权限是否存在,再通过 v-if进行隐藏

和标签内容都隐藏才对

为什么会这样呢?

我们在 hasPermi自定义指令中,打印下获取到的元素

图片

图片

id 为 pane-firstpane-second元素对应位置在哪里,我们找一下 需要先把指令去掉,因为元素都被我们删除的话,我们看不到具体DOM结构

图片

图片

图片

对比一下,明显可以看出 hasPermi自定义指令获取到只是标签内容的元素

那怎么办?

解决办法一:根据当前元素,一层层找到标签项,然后删除,这样是可以。该字符串是经过百分号编码的

  • route.path经过百分号编码的 URL 中的 pathname

  • //路径:http://127.0.0.1:3000/user?id=1console.log(route.path) // 输出 /userconsole.log(route.fullPath) // 输出 /user?id=1

    为了实现右边侧边栏,需要引入 element plus来快速搭建

     pnpm install element-plus

    main.ts改造一下,完整引入 element-plus

    import { createApp } from 'vue'import App from './App.vue'import ElementPlus from 'element-plus' // element-plus 组件库import 'element-plus/dist/index.css' // element-plus 组件库样式文件// 创建实例const setupAll = async () => {const app = createApp(App)app.use(ElementPlus)app.mount('#app')}setupAll()

    我们来编写下 侧边栏

    <!--@description: Sidebar--><template><div><el-menu active-text-color="#ffd04b" background-color="#304156" default-active="2" text-color="#fff" router><el-sub-menu :index="item.path" v-for="item in routers"><template #title>{{ item.name}}</template><el-menu-item :index="child.path" v-for="child in item.children">{{ child.name}}</el-menu-item></el-sub-menu></el-menu></div></template><script setup lang='ts'>import { filterRoutes } from '@/utils/router';import { computed } from 'vue';import { useRouter } from 'vue-router';const router = useRouter()// 通过计算属性,路由发生变化时更新路由信息const routers = computed(() => {return filterRoutes(router.getRoutes()) // router.getRoutes() 用于获取路由信息})</script>

    统一导出 layout 架构,加一点小样式

    <!--@description: layout index--><template><div class="app-wrapper"><Sidebar class="sidebar-container" /><App-Main class="main-container" /></div></template><script setup lang='ts'>import { ref, reactive } from 'vue'import Sidebar from './components/Sidebar.vue'import AppMain from './components/AppMain.vue'</script><style scoped>.app-wrapper {display: flex;}.sidebar-container {width: 200px;height: 100vh;background-color: #304156;color: #fff;}.main-container {flex: 1;height: 100vh;background-color: #f0f2f5;}</style>

    pnpm run serve运行一下

    图片

    页面权限管理

    通常我们实现页面权限管理,比较常见的方案是,有权限的路由信息由后端传给前端,前端再根据路由信息进行渲染

    我们先安装下 pinia模拟下后端传过来的数据

    pnpm install pinia

    图片

    import { defineStore } from "pinia";interface AuthStore {// 菜单menus: any[];}export const useAuthStore = defineStore("authState", {state: (): AuthStore => ({menus: [{path: "/routing",component: null,redirect: "user",children: [{path: "/routing/user",component: "/user.vue",name: "用户管理",meta: {},},{path: "/routing/menu",component: "/menu.vue",name: "菜单管理",meta: {},}],name: "系统管理",meta: undefined,},]}),getters: {},actions: {},});

     好了,我们把模拟的路由数据,加到本地路由中

    // permission.tsimport router from './router'import type { RouteRecordRaw } from 'vue-router'import { formatRoutes } from './utils/router'import { useAuthStore } from '@/store';import { App } from 'vue';// 路由加载前router.beforeEach(async (to, from, next) => {const { menus } = useAuthStore()routerList.forEach((route) => {router.addRoute(menus as unknown as RouteRecordRaw) // 动态添加可访问路由表})next()})// 路由跳转之后调用router.afterEach((to) => { })

    报错了,为什么呢?

    对比路由表的数据,原来,组件模块的数据与公共路由的数据不一致

    图片

    我们需要把模拟后端传过来的数据处理一下

    // router.tsimport Layout from '@/layout/index.vue';import type { RouteRecordRaw } from 'vue-router'/* 处理从后端传过来的路由数据 */export const formatRoutes = (routes: any[]) => {const formatedRoutes: RouteRecordRaw[] = []routes.forEach(route => {formatedRoutes.push({...route,component: Layout, // 主要是将这个 null -> 组件children: route.children.map((child: any) => {return {...child,component: () => import(`@/views${child.component}`), // 根据 本地路径配置页面路径}}),})})return formatedRoutes;}

    再修改下 permission.ts​​​​​​​​​​​​​​

    import router from './router'import type { RouteRecordRaw } from 'vue-router'import { formatRoutes } from './utils/router'import { useAuthStore } from '@/store';import { App } from 'vue';// 路由加载前router.beforeEach(async (to, from, next) => {const { menus } = useAuthStore()const routerList = menusrouterList.forEach((route) => {router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表})next()})// 路由跳转之后调用router.afterEach((to) => { })

    main.ts引入一下

     import './permission'

    可以正常访问了

    图片

    按钮权限

    除了页面权限,外我们还有按钮权限

    可以通过自定义指令来完成,permission.ts中定义一下

    /* 按钮权限 */export function hasPermi(app: App<Element>) {app.directive('hasPermi', (el, binding) => {const { permissions } = useAuthStore()const { value } = bindingconst all_permission = '*:*:*'if (value && value instanceof Array && value.length > 0) {const permissionFlag = valueconst hasPermissions = permissions.some((permission: string) => {return all_permission === permission || permissionFlag.includes(permission)})if (!hasPermissions) {el.parentNode && el.parentNode.removeChild(el)}} else {throw new Error('权限不存在')}})}export const setupAuth = (app: App<Element>) => {hasPermi(app)}

     需要挂载到 main.ts​​​​​​​

    import { createApp } from 'vue'import App from './App.vue'import { setupRouter } from './router/index'import ElementPlus from 'element-plus'import { createPinia } from 'pinia'import { setupAuth } from './permission'import 'element-plus/dist/index.css'import './permission'// 创建实例const setupAll = async () => {const app = createApp(App)setupRouter(app)setupAuth(app)app.use(ElementPlus)app.use(createPinia())app.mount('#app')}setupAll()

    还是在 store那里加一下模拟数据

    ​​​​​​​​​​​​​​​​​​​​​

    export const useAuthStore = defineStore("authState", {state: (): AuthStore => ({menus: [{path: "/routing",component: null,redirect: "user",children: [{path: "/routing/user",component: "/user.vue",name: "用户管理",meta: {},},{path: "/routing/menu",component: "/menu.vue",name: "菜单管理",meta: {},}],name: "系统管理",meta: undefined,},],permissions: [// '*:*:*', // 所有权限'system:user:create','system:user:update','system:user:delete',]}),});

     user.vue加入几个按钮,使用自定义指令​​​​​​​

    <!-- user.vue --><template><div><el-button type="primary" v-hasPermi="['system:user:create']">创建</el-button><el-button type="primary" v-hasPermi="['system:user:update']">更新</el-button><el-button type="primary" v-hasPermi="['system:user:delete']">删除</el-button><el-button type="primary" v-hasPermi="['system:user:admin']">没权限</el-button></div></template>

    system:user:admin这个权限没有配置,无法显示

    图片

    加一下权限

    图片

    图片

    扩展

    用户权限我们使用 v-hasPermi自定义指令,其原理是通过删除当前元素,来实现隐藏

    如果使用 Element Plus的标签页呢

    我们在 src/views/home.vue 写一下基本样式​​​​​​​

    <!--@description: 主页--><template><div><el-tabs><el-tab-pane label="标签一" name="first">标签一</el-tab-pane><el-tab-pane label="标签二" name="second">标签二</el-tab-pane></el-tabs></div></template>​​​​​​​

    图片

    我们加下按钮权限控制​​​​​​​

    <template><div><el-tabs v-model="activeName"><el-tab-pane label="标签一" v-hasPermi="['system:tabs:first']" name="first">标签一</el-tab-pane><el-tab-pane label="标签二" name="second">标签二</el-tab-pane></el-tabs></div></template>

    图片

    因为这个权限我们没有配置,标签页内容隐藏了,这没问题

    但是,标签没隐藏啊,通常要是标签一没权限,应该是标签项、但是这样太麻烦了,也只能用于标签页,那要是其他组件有这样的问题咋办

    解决办法二:我们写一个函数判断权限是否存在,再通过 v-if进行隐藏

    图片

     ​​​​​​

    export function checkPermi(value: string[]) {const { permissions } = useAuthStore()const all_permission = '*:*:*'if (value && value instanceof Array && value.length > 0) {const permissionFlag = valueconst hasPermissions = permissions.some((permission: string) => {return all_permission === permission || permissionFlag.includes(permission)})if (!hasPermissions) {return false}return true}}

    src/views/home.vue,引入下 checkPermi​​​​​​​

    <!--@description: 主页--><template><div><el-tabs v-model="activeName"><el-tab-pane label="标签一" v-if="checkPermi(['system:tabs:first'])" name="first">标签一</el-tab-pane><el-tab-pane label="标签二" name="second">标签二</el-tab-pane></el-tabs></div></template><script setup lang='ts'>/* ------------------------ 导入 与 引用 ----------------------------------- */import { ref } from 'vue'import { checkPermi } from '@/permission';/* ------------------------ 变量 与 数据 ----------------------------------- */const activeName = ref('first')</script>

    小结

    页面权限

    不同用户,具有不同页面访问权限,对应权限的路由信息由后端返回。404页面这些

    因为是用 typescript编写的,我们需要加一下声明文件,定义下 remainingRouter的类型

    // router.d.tsimport type { RouteRecordRaw } from 'vue-router'import { defineComponent } from 'vue'declare module 'vue-router' {interface RouteMeta extends Record<string | number | symbol, unknown> {hidden?: booleanalwaysShow?: booleantitle?: stringicon?: stringnoCache?: booleanbreadcrumb?: booleanaffix?: booleanactiveMenu?: stringnoTagsView?: booleanfollowAuth?: stringcanTo?: boolean}}type Component<T = any> =| ReturnType<typeof defineComponent>| (() => Promise<typeof import('*.vue')>)| (() => Promise<T>)declare global {interface AppRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {name: stringmeta: RouteMetacomponent?: Component | stringchildren?: AppRouteRecordRaw[]props?: RecordablefullPath?: stringkeepAlive?: boolean}interface AppCustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {icon: anyname: stringmeta: RouteMetacomponent: stringcomponentName?: stringpath: stringredirect: stringchildren?: AppCustomRouteRecordRaw[]keepAlive?: booleanvisible?: booleanparentId?: numberalwaysShow?: boolean}}

    接下来编写,创建路由、导出路由

    import type { App } from 'vue'import type { RouteRecordRaw } from 'vue-router'import { createRouter, createWebHashHistory } from 'vue-router'import remainingRouter from './modules/remaining'// 创建路由实例const router = createRouter({history: createWebHashHistory(), // createWebHashHistory URL带#,createWebHistory URL不带#strict: true,routes: remainingRouter as RouteRecordRaw[],scrollBehavior: () => ({ left: 0, top: 0 })})// 导出路由实例export const setupRouter = (app: App<Element>) => {app.use(router)}export default router

    main.ts中导入下

    import { createApp } from 'vue'import App from './App.vue'import { setupRouter } from './router/index' // 路由import ElementPlus from 'element-plus'import 'element-plus/dist/index.css'// 创建实例const setupAll = async () => {const app = createApp(App)setupRouter(app)app.mount('#app')}setupAll()

    接下来写下 Layout 架构

    我们要实现的效果,是一个后台管理页面的侧边栏,点击菜单右边就能跳转到对应路由所在页面

    图片

    创建

    AppMain.vue右边路由跳转页

    Sidebar.vue侧边栏

    index.vue作为 layout 架构的统一出口

    图片

    <!--@description: AppMain--><template><div><router-view v-slot="{ Component, route }"><transition name="fade-transform" mode="out-in"> <!-- 设置过渡动画 --><keep-alive><component :is="Component" :key="route.fullPath" /></keep-alive></transition></router-view></div></template>

    上面是一种动态路由的固定写法,需要与的路由配置进行对应

    其中最主要的就是 <component :is="Component" :key="route.fullPath" />中的 key,这是为确定路由跳转对应页面的标识,没这个就跳不了

    有一个小知识点

    • route.fullPath拿到的地址是包括 searchhash在内的完整地址。

    下一篇:504 GATEWAY