vue3配合element-plus构建权限管理

2,373 阅读7分钟

路由权限概览

和原先vue2版本的流程相似

image.png

约定数据格式

和后端确认数据格式

{
    children: [ // 二级路由
        {
            children: null, // 三级路由,children里有内容说明有下级,需要继续递归
            icon: null, // 图标
            id: 32, // 菜单id
            isShow: 1, // 是否展示
            name: "网站管理", // 菜单名字
            open: null,
            orderNum: 0, // 排序
            parentId: 1, // 父级ID
            parentName: "权限管理", // 父级名字
            perms: "",
            type: 1, // 类型 0是目录,1是菜单
            url: "/authority/subsite" // 菜单地址
        }
    ],
    icon: "system",
    id: 1,
    isShow: 1,
    name: "权限管理",
    open: null,
    orderNum: 0,
    parentId: 0,
    parentName: null,
    perms: null,
    type: 0, // 类型 0是目录,1是菜单
    url: "/authority" // 目录地址
}

路由设计

路由搭配vuex,sessionStorage,NProgress食用

1. 加入progress,实现导航进度条效果

配置

// 简单配置
NProgress.inc(2);
NProgress.configure({ easing: 'ease', speed: 500, showSpinner: false }); // 动作

使用

// 导航跳转开始
router.beforeEach(async (to, from, next) => {
  NProgress.start();
})

// 导航跳转结束
router.afterEach((to, from) => {
  NProgress.done();
});

2. 配置router

1. 配置默认路由

登录页面肯定是默认加载的,所以放在默认路由里面,前端直接获取

有不需要权限的页面,都可以放在这

// 默认路由,登录页面肯定是不需要
export const routes = [
  {
    path: '/login',
    name:'login',
    component: () => import('@/views/login_page/index.vue'),
    meta:{
      title:'登录界面',
      show:true
    }
  },
  {
    path: '/vuex',
    name:'vuex',
    component: () => import('@/views/vuex_page/index.vue'),
    meta:{
      title:'测试界面2',
      show:true
    }
  },
];

2. 权限页面控制

主要逻辑就和上面的流程图一样

image.png

router.beforeEach(async (to, from, next) => {
 
  if (to.meta.title) {
    document.title = to.meta.title
  }
  // 获取缓存的登录状态
  let hasLogin = sessionStorage.getItem("token")
  
  if (hasLogin) { // 有登录状态
    console.log('state', to, from)
    // 判断vuex中是否有存入路由数据
    if (store.state.menuList.length === 0) {
      
      // 进入到这一步用户已经登录过,但是又刷新了浏览器,导致路由清空了,所以要在vuex中重新请求路由
      let res = await store.dispatch('getMenuList')
      // code 不为200 时候,说明路由接口报错,需要重新登录
      if (res !== 200) {
        // 清除storage缓存
        clearLoginInfo()
        // 跳转登录页面
        next({
          path: '/login',
          replace: true
        })
        // element报错提示
        ElMessageBox.alert('菜单获取失败,请重新登录', '警告', {
          confirmButtonText: '确定',
          callback: () => { 
          }
        })
      } else { // 有路由数据
        // 通过addRoute动态加入路由信息
        store.state.menuList.map((item) => {
          router.addRoute(item);
        })
        // router.addRoutes是异步的,使用 next({ ...to, replace: true })重新载入
        next({...to, replace: true })
      }
    }
    if (to.name === 'login') { // 已经登录过,但是访问的是login,强制跳转至首页
      // 这里设置的是vuex中首个导航栏的path地址
      next({
        path: router.getRoutes()[0].path,
      })
    } else { // 正常跳转
      if (to.matched.length === 0) { // 未匹配到路由, 强制首页
        next({
          path: router.getRoutes()[0].path,
        })
        // 导航状态自动更新到首个菜单
        sessionStorage.setItem('activeIndex', router.getRoutes()[0].meta.menuId)
      } else { // 正常跳转✔
        next()
      }
    }
  } else { // 没有登录,则强制跳转登录页
    // 没有登录想访问其他页面,跳转至
    if (to.name !== 'login') {
      next({
        path: '/login',
      })
    } else {
      next()
    }
  }
});

3. router页面整体配置

import { createRouter, createWebHistory } from 'vue-router'
import { ElMessageBox } from 'element-plus';
import NProgress from 'nprogress';
import store from '../store'
import { clearLoginInfo } from "../utils/common";

// 简单配置
NProgress.inc(2);
NProgress.configure({ easing: 'ease', speed: 500, showSpinner: false }); // 动作

// 默认路由
export const routes = [
  {
    path: '/login',
    name:'login',
    component: () => import('@/views/login_page/index.vue'),
    meta:{
      title:'登录界面',
      show:true
    }
  },
  {
    path: '/vuex',
    name:'vuex',
    component: () => import('@/views/vuex_page/index.vue'),
    meta:{
      title:'测试界面2',
      show:true
    }
  },
];

export const router = createRouter({
  // 使用 hash 模式构建路由( url中带 # 号的那种)
  // history: createWebHashHistory(),
  // 使用 history 模式构建路由 ( url 中没有 # 号,但生产环境需要特殊配置)
  history: createWebHistory(),
  // history: createWebHashHistory(),
  routes:routes
});

router.beforeEach(async (to, from, next) => {
  NProgress.start();
  if (to.meta.title) {
    document.title = to.meta.title
  }
  // 获取缓存的登录状态
  let hasLogin = sessionStorage.getItem("token")
  // console.log('state', hasLogin, to, from)
  if (hasLogin) { // 有登录状态
    console.log('state', to, from)
    // 判断vuex中是否有存入路由数据
    if (store.state.menuList.length === 0) {
      
      // 进入到这一步用户已经登录过,但是又刷新了浏览器,导致路由清空了,所以要在vuex中重新请求路由
      let res = await store.dispatch('getMenuList')
      // code 不为200 时候,说明路由接口报错,需要重新登录
      if (res !== 200) {
        clearLoginInfo()
        next({
          path: '/login',
          replace: true
        })
        // 报错
        ElMessageBox.alert('菜单获取失败,请重新登录', '警告', {
          confirmButtonText: '确定',
          callback: () => {
            // clearLoginInfo()
            // 跳转登录页
            
          }
        })
      } else { // 有路由数据
        store.state.menuList.map((item) => {
          router.addRoute(item);
        })
        // router.addRoutes是异步的,使用 next({ ...to, replace: true })重新载入
        next({...to, replace: true })
      }
    }
    if (to.name === 'login') { // 已经登录过,但是访问的是login,强制跳转至首页
      // 这里设置的是vuex中首个导航栏的path地址
      next({
        path: router.getRoutes()[0].path,
      })
    } else { // 正常跳转
      if (to.matched.length === 0) { // 未匹配到路由, 强制首页
        next({
          path: router.getRoutes()[0].path,
        })
        sessionStorage.setItem('activeIndex', router.getRoutes()[0].meta.menuId)
      } else {
        next()
      }
      // console.log('333', router.getRoutes())
    }
  } else { // 没有登录,则强制跳转登录页
    // 没有登录想访问其他页面,跳转至
    if (to.name !== 'login') {
      next({
        path: '/login',
      })
    } else {
      next()
    }
  }
});


router.afterEach((to, from) => {
  NProgress.done();
  console.log(to, from, 3333)
  // 刷新后重新定向导航按钮展示
  if (sessionStorage.getItem('activeIndex') !== to.meta.menuId) {
    sessionStorage.setItem('activeIndex', to.meta.menuId)
  }
});

export default router

3. vuex配置

这里的逻辑是每次刷新或者跳转路由,都需要判断页面是否还有权限,菜单是否正常返回

import { createStore, createLogger } from 'vuex'
import HttpAxios from '../utils/httpTool'

const debug = process.env.NODE_ENV !== 'production'

const store = createStore({
  state: {
    count: 0,
    collapse: false,
    menuList: []
  },
  getters: {
    menuList: state => state.menuList
  },
  mutations: {
    setMenuList (state, value) {
      // 存入数据
      state.menuList = value;
    }
  },
  actions: {
    // 获取动态路由
    async getMenuList({commit}) {
      // 通过接口获取路由数据
      let url='/sys/menu/nav';
      let requestData = {};
    
      let res = await HttpAxios.axiosGet(url, requestData); // 这里使用axios,晚点出一篇axios配置到原型链的文章
      if (res.code === 200 && res.data.menus.length > 0) { // 成功
        // 生成路由信息
        let menus = needRoutes(res.data.menus)
        ,
        commit('setMenuList',  menus)
        return res.code
      } else { // 失败
        return 500
      }
    }
  },
  modules: {
  },
  strict: debug,
  plugins: debug ? [createLogger()] : []
});

// 生成路由数据
let needRoutes = (data) => {
  // 判断是否是数组
  if (!Array.isArray(data)) {
    return new TypeError('arr must be an array.');
  }
  let arr = formatComponent(data)
  return arr;
}

// 递归函数,用来对组件和路由关联,进行异步渲染
let formatComponent = (data) => {
  data.map((obj) => {
    if (obj.url && /\S/.test(obj.url)) {
      if (obj.type === 1) { // 菜单逻辑
        obj.url = obj.url.replace(/^\//, '')
        const component = obj.url
        // 把后台返回的路由参数,拼接路径,路由对应的就是页面地址
        obj.component =  () => import(`@/views/${component}/index.vue`)
        // 通过正则来处理路由
        obj.path = '/' + obj.url.replace('/', '-').match(/(\S*)-/)[1] + '/' + obj.url.replace('/', '-')
        obj.title = obj.name
        let name = obj.path.split('/')
        obj.name = name[name.length-1]
        obj.meta = {
          menuId: obj.id.toString(),
          title: obj.title,
          isDynamic: true,
          show: obj.isShow === 1 ? true: false,
          isTab: true,
        }
      } else if (obj.type === 0) { // 目录逻辑
        obj.component = () => import('@/views/home_page/index.vue')
        obj.path = obj.url
        obj.meta = {
          menuId: obj.id.toString(),
          title: obj.name,
          isDynamic: true,
          show: false,
          isTab: true,
        }
      }
    }
    if (obj.children && obj.children.length > 0) { // children有长度,说明有子路由,进行递归
      return formatComponent(obj.children)
    }
  })
  return data
}

export default store

路由实现效果

以我这边设计的数据格式

{
    children: [ // 二级路由
        {
            children: null, // 三级路由,children里有内容说明有下级,需要继续递归
            icon: null, // 图标
            id: 32, // 菜单id
            isShow: 1, // 是否展示
            name: "网站管理", // 菜单名字
            open: null,
            orderNum: 0, // 排序
            parentId: 1, // 
            parentName: "权限管理",
            perms: "",
            type: 1, // 类型 0是目录,1是菜单
            url: "/authority/subsite" // 菜单地址
        }
    ],
    icon: "system",
    id: 1,
    isShow: 1,
    name: "权限管理",
    open: null,
    orderNum: 0,
    parentId: 0,
    parentName: null,
    perms: null,
    type: 0, // 类型 0是目录,1是菜单
    url: "/authority" // 目录地址
}

当前有两层路由,那么二级路由是"/authority/subsite"

经过formatComponent函数处理之后,一级路由是/authority,二级路由就是/authority/authority-subsite

动态加入router之后

image.png

4. element-plus配置主页

1.page页面设置

为了方便前端页面构建,和配合路由设计 前端的页面结构是目录->菜单->子菜单,我这里暂时是二级菜单,没有涉及三级菜单

image.png

1. app.vue页面配置, 这里用了transition配置,和vue2还是有点不一样的

<template>
  <div id="app">
    <router-view v-slot="{ Component }">
      <transition name="slide-fade" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

2. 设置一个主页

新建一个homepage页面,配置一下,我这里配置的首页就是element官方的导航栏样式

image.png

1.在el-main里面配置的就是路由切换后的内容页
2.nav-menu组件是左侧导航
3.nav-header组件是头部展示
<template>
  <div id="homePage">
    <el-container style="height: 100%">
      <el-aside height="100%" style="transition: all .3s" :style="{width:collapse?56+'px':256+'px'}">
        <nav-menu></nav-menu>
      </el-aside>
      <el-container>
        <el-header style="padding: 0;height: 120px">
          <nav-header></nav-header>
        </el-header>
        <el-main style="padding: 20px;width: 100%;height: 100%;background: #EBEDF2">
          <router-view v-slot="{ Component }">
            <transition name="mode-fade" mode="out-in">
              <component :is="Component" />
            </transition>
          </router-view>
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

这里讲下nav-menu这个组件,这个组件需要用到一个子组件menu,menu这个组件是需要递归的

refRouter这个字段,就是存入vuex中的导航数据

<template>
  <div id="navMenu">
    <el-menu
            router
            class="el-menu-vertical-demo"
            :collapse="collapse"
            :default-active="activeIndex"
            el-menu
            background-color="#2A354E"
            text-color="#fff"
            active-text-color="#1890FF"
            @select="handleSelect"
            @open="handleOpen"
            @close="handleClose"
    >
      <Menu :routerList="refRouter"></Menu>
    </el-menu>
  </div>
</template>

menu组件, 这里的v-if判断就是对应之前数据格式定义,

// 1.当路由是目录的时候,如果有children,并且children有长度
<el-submenu v-if="item.children && item.children.length > 0" :key="item.path" :index="item.meta.menuId">
        <template #title style="padding-left:10px" v-if="!item.meta.show">
          <i class="el-icon-menu"></i>
          <span>{{ item.meta.title}}</span>
        </template>
        <!--  如果有子级数据使用递归组件 -->
        <Menu :routerList="item.children"></Menu>
</el-submenu>
// 路由是菜单,show这个字段就是用来判断是否展示菜单
<el-menu-item  v-if="!item.children && item.meta.show" :route="item.path" :index="item.meta.menuId" :key="item.path">
        <i class="el-icon-menu"></i>
        <span>{{item.meta.title}}</span>
</el-menu-item>

整个页面如下

<template>
    <template v-for="item in routerList">
      <el-submenu v-if="item.children && item.children.length > 0" :key="item.path" :index="item.meta.menuId">
        <template #title style="padding-left:10px" v-if="!item.meta.show">
          <i class="el-icon-menu"></i>
          <span>{{ item.meta.title}}</span>
        </template>
        <!--  如果有子级数据使用递归组件 -->
        <Menu :routerList="item.children"></Menu>
      </el-submenu>
      <el-menu-item  v-if="!item.children && item.meta.show" :route="item.path" :index="item.meta.menuId" :key="item.path">
        <i class="el-icon-menu"></i>
        <span>{{item.meta.title}}</span>
      </el-menu-item>
    </template>
</template>

目录结构如下

image.png

功能权限概览

考虑到需要精细到页面的权限控制,所以会在页面进入时,请求接口的时候做一个判断

1.权限主要是涉及数据的增删改
2.控制方式第一层,通过后台给的权限标识,控制按钮的展示
3.控制方式第二层,后台改写逻辑,没有分配权限的请求,直接过滤

权限的标识,是在获取菜单的时候一起获取的,获取之后直接存入缓存,这里可以直接看之前vuex的一步

// 缓存权限配置到storage
sessionStorage.setItem('permissions', JSON.stringify(res.data.authorities))

权限标识可以根据实际业务来,进行涉及,需要做的一个逻辑就是在按钮处进行判断

我这里直接写一个公共函数,用来判断是否有权限标识,如果有则返回true,没有则返回false

export function isAuth (key) {
  return JSON.parse(sessionStorage.getItem('permissions') || '[]').indexOf(key) !== -1 || false
}

那我在页面中使用的时候,可以直接加入这个条件, 我这里是直接加入原型链,在main.js 中直接引入

这里说下,vue3的原型链使用方式和vue2是不一样的

// 权限控制
import {isAuth} from "./utils/common";
app.config.globalProperties.isAuth = isAuth

最后在页面就可以直接用了

<el-button v-if="isAuth('sys:web:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>