二、Vue3 + Element-plus 搭建后台管理系统之功能点实现

401 阅读2分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情

二、Vue3 + Element-plus 搭建后台管理系统之功能点实现

2.1 登录/注销

1.在vuex中模拟请求,并保存在localStorage中
# store/user.js

// 模拟登录
const state = {
  token: localStorage.getItem('token') //获取localStorage中的token
};

const mutations = {
  setToken: (state, token) => {
    state.token = token; // 修改token
  }
};

const actions = {
  login({ commit }, userInfo) {
    const { username } = userInfo;
    console.log('store login', username);
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (username === "admin" || username === "jerry") {
              commit("setToken", username);
              localStorage.setItem('token', username);
              resolve();
            } else {
              reject("用户名、密码错误");
            }
        }, 10);
    });
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
};
2.从页面发起请求,等待请求成功后页面跳转
# views/Login.vue

<el-button class="submit" type="primary" @click="loginHandle">
    登录
</el-button>

<script>
export default {
    ...
    methods: {
        loginHandle() {
            this.$store
            .dispatch("user/login", { username: this.login.username })
            .then(() => {
              // 登录成功后进入首页
              this.$router.push({path: "/"});
            })
            .catch((error) => {
              alert(error);
            });
        }
    }
    ...
}
</script>
3.实现退出登录的功能
# components/header/index.vue

<div @click="logouthandle">退出登录</div>
 
export default {
    methods: {
       logouthandle() {
            this.$store.dispatch("user/resetToken");
            ...
        }
    }
}

# store/user.js

const actions = {
    ...
    resetToken({ commit }) {
        // 模拟清空令牌和角色状态
        return new Promise(resolve => {
            commit("setToken", ""); // 清空token状态
            localStorage.removeItem('token'); // 清空localStorage中token
            resolve();
        });
    }
    ...
}

2.2 页面访问权限

使用vue-router的全局前置守卫beforeEach来实现页面访问权限。

1.首先先把通用页面和权限页面区分出来,在权限页面中通过定义meta来确认权限。
# router/index.js

// 导出通用页面路由和权限页面路由
export const constRoutes = [ ... ];
export const asyncRoutes = [ 
    {
        path: "/authority",
        name: 'authority',
        redirect: '/authority/AuthorityOne',
        component: () => import("@/views/Home"),
        meta: {
          title: "权限页面",
          icon: "authority",
          roles: ['admin']
        },
        children:[ ... ]
    }
];
# store/permission.js

import { asyncRoutes, constRoutes } from "@/router";

const state = {
  routes: [],
};

const mutations = {
  setRoutes: (state, routes) => {
    state.addRoutes = routes;
    state.routes = constRoutes.concat(routes);
  }
};

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      // 根据角色做过滤处理
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
      commit("setRoutes", accessedRoutes);
      resolve(accessedRoutes);
    });
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};
2.通过vue-router的全局前置守卫来判断当前用户访问页面的权限,并循环添加至路由中。
# permission.js

import router from './router'
import store from './store'

// 白名单
const whiteList = ['/login']

router.beforeEach(async (to, from, next) => {

  // 找不到页面跳转404
  if (to.matched.length === 0) {
    next('/404');
  }

  // 获取localStorage中token,判断用户是否登录
  const hasToken = localStorage.getItem('token')

  if (hasToken) {
    // 当已登录时,当访问登录页时则重定向至首页
    if (to.path === '/login') {
      next('/')
    } else {
      // 判断是否获取角色信息
      const hasRoles = store.getters.roles && store.getters.roles.length > 0;
      if (hasRoles) {
        // 已获取
        next()
      } else {
        // 未获取
        try {
          // 获取用户信息
          const { roles } = await store.dispatch('user/getInfo')

          // 根据用户角色过滤出可访问的路由
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

          // 将过滤出的页面循环添加至路由中
          accessRoutes.forEach(ele => {
            router.addRoute(ele)
          });

          next({ ...to, replace: true })
        } catch (error) {
          // 令牌过期、网络错误等原因会导致出错,需重置令牌并重新登录
          await store.dispatch('user/resetToken')
          next(`/login?redirect=${to.path}`)
          alert(error || '未知错误')
        }
      }
    }
  } else {
    // 未登录
    if (whiteList.indexOf(to.path) !== -1) {
      // 白名单
      next() 
    } else {
      // 重定向至登录页,带着当前路径方便登录成功后重定向
      next(`/login?redirect=${to.path}`) 
    }
  }
})

当然,页面访问权限的路由也是可以通过后端返回拼接好的字符串来实现的。但需要前端指定一下component对应的页面。

component: () => import("@/views/xxx/xxxx")

2.3 svg全局组件

通过配置webpack的方法实现svg文件的全局使用。svg文件下载地址

1.首先先安装依赖
npm i svg-sprite-loader -D
# OR
yarn add svg-sprite-loader -D
2.然后将svg文件存放在icons文件夹下,并通过该路径获取全部的svg文件。

image.png

# icons/index.js

const req = require.context('./svg', false, /\.svg$/);
req.keys().map(req)
3.配置webpack
# vue.config.js

const { defineConfig } = require('@vue/cli-service')
const path = require('path');
// 获取绝对路径
function resolve(dir) {
  return path.join(__dirname, dir)
}

module.exports = defineConfig({
    ...
    chainWebpack(config) {
        // 排除icons⽬录中svg⽂件处理
        config.module.rule("svg")
          .exclude.add(resolve("src/icons"))
    
        // 使用svg-sprite-loader处理icons⽬录中的svg文件
        config.module.rule('icons')
          .test(/\.svg$/)
          .include.add(resolve('./src/icons')).end()
          .use('svg-sprite-loader')
          .loader('svg-sprite-loader')
          .options({ symbolId: 'icon-[name]' })
    }
})
4.全局引入icons中的所有svg文件。
# main.js 

// svg icon 引⼊
import '@/icons'

5.将svg图标的功能封装成组件,方便后边调用。
# components/SvgIcon.vue

<template>
  <svg :class="svgClass" v-on="$attrs">
    <use :xlink:href="iconName" />
  </svg>
</template>
<script>
export default {
  name: "SvgIcon",
  props: {
    iconClass: {
      type: String,
      required: true,
    },
    className: {
      type: String,
      default: "",
    },
  },
  computed: {
    iconName() {
      return `#icon-${this.iconClass}`;
    },
    svgClass() {
      if (this.className) {
        return "svg-icon " + this.className;
      } else {
        return "svg-icon";
      }
    },
  },
};
</script>
<style scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  overflow: hidden;
}
</style>

6.通过引入组件使用svg图标。也可以把封装的SvgIcon组件注册到全局,这样就不用每个页面都引入了。
<SvgIcon :iconClass="item.icon" />

<script>
import SvgIcon from "@/components/SvgIcon.vue";
export default {
  name: "menuItem",
  components: { SvgIcon },
  props: {
    item: {
      type: Object,
      required: true,
    },
  }
};
</script>

2.4 根据权限生成侧边栏

这个功能是使用element-plus的Menu菜单组件实现,将Menu菜单组件通过递归的方法封装成组件。数据是从vue-router中获取,为了满足Menu菜单所需要的数据格式,项目中会通过递归的方法来清洗数据。

1.使用递归的方法封装Menu菜单,并通过vuex的mapGetters方法获取Menu所需的数据。
# components/menu/index.vue

<template>
  <el-menu
    active-text-color="rgba(191, 203, 217,1)"
    background-color="rgba(32, 112, 165, 1)"
    text-color="#fff"
    class="el-menu-vertical-demo"
    :default-active="current_route"
    :collapse="isCollapse"
    @open="handleOpen"
    @close="handleClose"
  >
    <MenuItem v-for="route in menuLists" :key="route.key" :item="route" />
  </el-menu>
</template>

<script>
import MenuItem from "./MenuItem.vue";
import { mapGetters } from "vuex";
export default {
  name: "LayoutMenu",
  components: { MenuItem },
  computed: {
    ...mapGetters(["permission_menus", "current_route"]),
  },
  props: {
    isCollapse: {
      type: Boolean,
      required: true,
    },
  },
  mounted() {
    // 获取验证过权限的侧边栏
    this.permission_menus.then((res) => {
      this.menuLists = res;
      console.log(this.menuLists);
    });
  },
  data() {
    return {
      menuLists: [],
    };
  },
  methods: {
    handleOpen(key, keyPath) {
      console.log(key, keyPath);
    },
    handleClose(key, keyPath) {
      console.log(key, keyPath);
    },
  },
};
</script>

<style scoped>
.el-menu {
  border-right: 0;
}
</style>

# components/menu/MenuItem.vue

<template>
  <template v-if="!item.children">
    <router-link :to="item.path">
      <el-menu-item :index="item.key">
        <el-icon>
          <SvgIcon
            v-if="item.icon"
            :iconClass="item.icon"
            className="svgStyle"
          />
        </el-icon>
        <template #title> {{ item.name }}</template>
      </el-menu-item>
    </router-link>
  </template>
  <template v-else>
    <el-sub-menu :index="item.key">
      <template #title>
        <el-icon>
          <SvgIcon
            v-if="item.icon"
            :iconClass="item.icon"
            className="svgStyle"
          />
        </el-icon>
        <span>{{ item.name }}</span>
      </template>
      <!--开始递归-->
      <menuItem
        v-for="route in item.children"
        :key="route.key"
        :item="route"
      ></menuItem>
    </el-sub-menu>
  </template>
</template>

<script>
import SvgIcon from "@/components/SvgIcon.vue";
export default {
  name: "menuItem", // 名称必须定义,递归的时候会用到。
  components: { SvgIcon },
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
};
</script>

<style>
.svgStyle {
  width: 20px !important;
  height: 20px !important;
  color: #fff;
}

a {
  text-decoration: none;
  color: #fff;
}
</style>

2.在Vuex中实现Menu所需的数据
# store/index.js

import { createStore } from 'vuex'

let menus = [];
function getMenusLists(item, menu = {}) {
  item.forEach(ele => {
    if (ele.children) {
      let lists = {}
      if (ele.meta) {
        lists.name = ele.meta.title
        lists.key = ele.meta.icon
        lists.icon = ele.meta.icon
        lists.children = []
      }
      getMenusLists(ele.children, lists)
      menus.push(lists)
    } else {
      if (ele.meta) {
        if (menu.children) {
          menu.children.push({
            name: ele.meta.title,
            key: ele.meta.icon,
            icon: ele.meta.icon,
            path: ele.path
          })
        } else {
          menu.name = ele.meta.title
          menu.key = ele.meta.icon
          menu.icon = ele.meta.icon
          menu.path = ele.path
        }
      }
    }
  });
}

export default createStore({
    getters: {
        permission_menus: async state => {
          const routesLists = state.permission.routes.filter(ele => !ele.hidden)
          // 递归
          getMenusLists(routesLists)
          return menus
        }
    }
})

2.5 按钮权限

使用全局指令的方式实现按钮的权限。

1.根据当前登录的用户角色,来判断按钮是否展示。
# directives/permission.js

import store from "@/store";

const permission = {
  mounted(el, binding) {
    // 获取指令的值: v-permission="['admin']"
    const { value: perRoles } = binding;
    // 获取用户角色
    const roles = store.getters && store.getters.roles;
    if (perRoles && perRoles instanceof Array && perRoles.length > 0) {
      // 判断用户角色中有没有按钮要求的角色
      const hasPermission = roles.some(role => {
        return perRoles.includes(role);
      });

      // 没有权限则删除当前dom
      if (!hasPermission) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`请按如下方式定义指令权限:v-permission="['admin','editor']"`);
    }
  }
}

export default permission
2.注册全局指令
# main.js

import vPermission from "./directives/permission";

const app = createApp(App)
app.directive('permission', vPermission)
app.mount('#app')
3.使用
# views/home/HomePage.vue

<el-button v-permission="['admin']" type="primary">admin</el-button>
<el-button v-permission="['jerry']" type="success">jerry</el-button>
<el-button v-permission="['admin', 'jerry']" type="info">
 admin && jerry
</el-button>

2.6 封装axios

1.安装
npm install axios
#OR
yarn add axios
2.集中处理请求和响应的相关操作。
# utils/request.js

// axios请求封装
import Axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import store from '@/store'


// 创建axios实例
const axios = Axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // 从环境中获取url地址,也可写死。
  withCredentials: true, // 跨域时发送cookies是设置为 true
  timeout: 5000 // 超时时间
});

// 请求拦截
axios.interceptors.request.use(
  config => {
    // 获取token
    const token = localStorage.getItem('token')
    if (token) {
      // 请求时将令牌放进请求头中
      config.headers["Authorization"] = 'Bearer ' + token;
    }
    return config;
  },
  error => {
    // 请求错误预处理
    // console.log(error)
    return Promise.reject(error);
  }
)

// 响应拦截
axios.interceptors.response.use(
  response => {
    const res = response.data;
    // 接口错误,规则可自定义
    if (res.code !== 1) {
      ElMessage({
        message: res.message || "Error",
        type: "error",
        duration: 5 * 1000
      });

      // 自定义规则,根据不同的错误码来进行相应的操作
      if (res.code === 10008 || res.code === 10012 || res.code === 10014) {
        ElMessageBox.confirm(
          "登录异常,请重新登录",
          "登录信息确认",
          {
            confirmButtonText: "重新登录",
            cancelButtonText: "取消",
            type: "warning"
          }
        ).then(() => {
          // 清空 localStorage 重新登陆
          store.dispatch("user/resetToken").then(() => {
            location.reload();
          });
        });
      }

      return Promise.reject(new Error(res.message || "Error"));
    } else {
      // 接口正确直接返回
      return res;
    }
  },
  error => { // 请求本身异常
    ElMessage({
      message: error.message,
      type: "error",
      duration: 5 * 1000
    });
    return Promise.reject(error);
  }
)

export default axios;

3.定义相关接口,并集中管理。
# api/user.js

import axios from '@/utils/request'

// 登录请求
export function login(data) {
  return axios.post('/user/login', data)
}

// 获取角色
export function getInfo() {
  return axios.get('/user/info')
}

4.使用
# store/user.js
... 

const actions = {
  login({ commit }, userInfo) {
    return login(userInfo).then((res) => {
      commit("setToken", res.data);
      localStorage.setItem("token", res.data);
    });
  },
  getInfo({ commit, state }) {
    return getInfo(state.token).then(({ data: roles }) => {
      commit("setRoles", roles);
      return { roles }
    })
  }
}

export default {
  ...
  actions
};

另外需要注意的是,在server/index.js中存放着服务端代码,是通过模拟实现的,并未连接数据库。

2.7 顶部进度条

1.安装
npm install nprogress
# OR
yarn add nprogress
2.方法封装
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

//全局进度条的配置
NProgress.configure({
  easing: 'ease',  // 动画方式    
  speed: 1000,  // 递增进度条的速度
  showSpinner: false, // 是否显示加载icon
  trickleSpeed: 100, // 自动递增间隔    
  minimum: 0.3 // 初始化时的最小百分比
})

// 打开进度条
export const start = () => {
  NProgress.start()
}

// 关闭进度条
export const close = () => {
  NProgress.done()
}
3.在路由的全局守卫中使用,在前置守卫中开始,在后置守卫中结束。
# permission.js

import { start, close } from '@/utils/nprogress';

router.beforeEach(async (to, from, next) => {
    start();
    ...
})

router.afterEach(() => {
  // 关闭加载进度条
  close();
});

2.8 面包屑导航

目的是为了显示当前页面的路径,快速返回之前的任意页面。使用element-plus的Breadcrumb面包屑组件来实现。

# components/header/index.vue

<div class="leftInfo">
    <el-breadcrumb separator="/">
      <el-breadcrumb-item v-for="item in lists" :key="item.path">
        <router-link :to="item.path">{{ item.meta.title }}</router-link>
      </el-breadcrumb-item>
    </el-breadcrumb>
</div>

<script>
export default {
    data() {
        return {
            lists: []
        };
    },
    created() {
        // this.$route.matched包含当前路由的所有嵌套路径片段的路由记录
        this.lists = this.$route.matched;
    },
    watch: {
        $route(to) {
          this.lists = to.matched;
        },
    },
}
</script>

2.9 全屏/取消全屏

1.安装
npm install screenfull
# OR 
yarn add screenfull
2.实现全屏/取消全屏
# components/header/index.vue

<SvgIcon
    class="iconStyle"
    :iconClass="iconName"
    @click="handleScreenfull"
/>
    
<script>
import screenfull from "screenfull";  
export default {
    mounted() {
    // 监听全局事件
    screenfull.on("change", () => {
      // icon切换
      this.iconName = screenfull.isFullscreen ? "quxiaoquanping" : "quanping";
    });
    },
    methods:{
        handleScreenfull() {
            if (!screenfull.isEnabled) {
                this.$message({
                  message: "不支持全屏",
                  type: "warning",
                });
                return false;
            }
            // 全屏/取消全屏切换方法
            screenfull.toggle();
        },
    }
}
</script>

2.10 标签导航栏

使用vuex管理标签导航栏的状态,通过监听路由的变化,动态添加标签导航。在标签导航的右上角设置关闭按钮,点击删除当前标签导航。

1.创建vuex管理标签导航栏
# store/route.js

const state = {
  // 规定默认存在的页面,isDefault为true,不允许删除;isSelect控制当前选中的状态;
  tabRoutes: [{ name: '首页', path: '/home', key: 'home', isDefault: true, isSelect: false }],
  currentRoute: '', // 当前选中
};

const mutations = {
  setTabRoutes: (state, routes) => {
    // 判断当前标签是否存在,存在改变选中状态,不存在则添加
    const isExist = state.tabRoutes.filter(item => item.name == routes.meta.title)
    state.tabRoutes.forEach(element => {
      if (element.name == routes.meta.title) {
        state.currentRoute = element.key
        element.isSelect = true
      } else {
        element.isSelect = false
      }
    });
    if (isExist.length == 0) {
      state.currentRoute = routes.name
      state.tabRoutes.push({
        name: routes.meta.title,
        path: routes.path,
        key: routes.name,
        isDefault: false,
        isSelect: true,
      });
    }
  },
  removeTabRoutes: (state, routes) => {
    const num = state.tabRoutes.findIndex(item => item.name == routes.name)
    state.tabRoutes.splice(num, 1)

    const len = state.tabRoutes.length;
    state.currentRoute = state.tabRoutes[len - 1].key
  },
  cleanTabRoutes: (state, routes) => {
    state.tabRoutes = routes
  }
};

const actions = {
  // 添加路由
  addTabRoutes({ commit }, roles) {
    return new Promise(resolve => {
      commit("setTabRoutes", roles);
      resolve();
    });
  },
  // 删除路由
  removeTabRoutes({ commit }, roles) {
    return new Promise(resolve => {
      commit("removeTabRoutes", roles);
      resolve();
    });
  },
  // 模拟清空令牌和角色状态
  resetTabRoutes({ commit }) {
    return new Promise(resolve => {
      commit("cleanTabRoutes", []);
      resolve();
    });
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};
2.操作添加或删除导航标签。
# components/header/index.vue

<script>
export default {
    created() {
        // this.$route.matched包含当前路由的所有嵌套路径片段的路由记录
        this.lists = this.$route.matched;
        // 初始化页面时根据当前访问路由添加导航标签
        const len = this.$route.matched.length;
        this.$store.dispatch("route/addTabRoutes", this.$route.matched[len - 1]);
    },
    watch: {
        $route(to) {
          // 监听路由的变化来添加导航标签
          this.$store.dispatch("route/addTabRoutes", to);
          this.lists = to.matched;
        },
    }
}
</script>
# views/Home.vue

<script>
export default {
    methods: {
        // 删除当前导航标签
        handleRemoveRoute(item) {
          this.$store.dispatch("route/removeTabRoutes", item).then(() => {
            const list = this.tab_routes.find((ele) => ele.isSelect);
            if (!list) {
              const len = this.tab_routes.length;
              this.$router.push({ path: this.tab_routes[len - 1].path });
            }
          });
        },
    }
}
</script>

2.11 侧边栏伸缩

1.根据isCollapse的值来控制菜单伸缩的状态。

这里有个小问题,在控制Dom元素宽度改变的时候,会出现Dom元素中内容闪动的情况。项目中是通过把左侧菜单定位布局,右侧内容靠右浮动的方式解决的。

# views/Home.vue

<template>
  <div class="home">
    <div class="sideBar" :class="{ sideBarActive: this.isCollapse }">
      <div class="title">{{ title }}</div>
      <SvgIcon class="toggle" :iconClass="iconName" @click="handleClick" />
      <div class="sideBarCont">
        <MenuComponent :isCollapse="isCollapse" />
      </div>
    </div>
    <div class="cont" :class="{ contActive: this.isCollapse }">
      ...
    </div>
  </div>
</template>

<script>
import MenuComponent from "@/components/menu";
import SvgIcon from "@/components/SvgIcon.vue";
export default {
  name: "HomePage",
  components: { MenuComponent, SvgIcon },
  data() {
    return {
      isCollapse: false,
      title: "VUE-ADMIN-MS",
      iconName: "shousuo"
    };
  },
  methods: {
    handleClick() {
      this.isCollapse = !this.isCollapse;
      this.title = this.isCollapse ? "MS" : "VUE-ADMIN-MS";
      this.iconName = this.isCollapse ? "zhankai" : "shousuo";
    }
  },
};
</script>

<style scoped>
.home {
  width: 100%;
  height: 100%;
  background: #f2f5fa;
  position: relative;
  overflow: hidden;
}

.home .sideBar {
  width: 210px;
  height: 100%;
  transition: width 0.3s;
  background: rgb(32, 112, 165);
  box-shadow: 5px 0px 5px rgb(240, 233, 233);
  box-sizing: border-box;
  position: absolute;
  color: #fff;
  top: 0;
  left: 0;
}

.home .sideBarActive {
  width: 65px !important;
}

.home .sideBar .title {
  height: 60px;
  line-height: 60px;
  font-size: 20px;
  padding: 0 20px;
  overflow: hidden;
}

.home .sideBar .toggle {
  width: 35px;
  height: 35px;
  position: absolute;
  top: 13px;
  right: -50px;
  color: rgba(51, 51, 51, 0.8);
  cursor: pointer;
}

.home .sideBar .toggle:hover {
  color: rgba(51, 51, 51, 1);
}

.home .sideBarCont {
  width: 100%;
  height: calc(100% - 60px);
  box-shadow: 5px 0px 5px rgb(240, 233, 233);
  overflow-x: hidden;
}

.home .cont {
  width: calc(100% - 210px);
  height: 100%;
   /* 右侧Dom元素右浮动,目的在于解决宽度改变,内容闪动的情况。 */
  float: right;
  margin-left: 210px;
  transition: width 0.3s;
  box-sizing: border-box;
}
.home .contActive {
  width: calc(100% - 65px);
  margin-left: 65px;
}

.home .cont .header {
  width: 100%;
  height: 60px;
  background: #fff;
  box-shadow: 5px 5px 5px rgb(240, 233, 233);
}
.home .cont .body {
  width: 100%;
  height: calc(100% - 110px);
  padding: 24px;
  box-sizing: border-box;
  overflow-y: auto;
}
</style>

2.12 动态换肤

动态换肤实现方式有很多,项目中此功能是使用css动态样式实现的。项目中只给部分元素绑定了动态换肤的功能,实现方式就是这样,大家有需要可以扩展。

1.创建一个公共的css样式文件,在根元素上创建主题变量。
# assets/css/default.css

:root {
  --theme-color: rgba(32, 112, 165, 1);
}
2.动态修改主题变量
# components/header/index.vue

export default {
    methods: {
        serStyleProperty(theme) {
          const el = document.documentElement;
          el.style.setProperty("--theme-color", theme); // 修改值
        },
    }
}
3.使用主题变量
.scrollbar-demo-item.active {
  background: var(--theme-color);
}