系统简介
- 此管理系统是基于Vite2和Vue3.0构建生成的后台管理系统。目的在于学习vite和vue3等新技术,以便于后续用于实际开发工作中;
- 本文章将从管理系统页面布局、vue路由鉴权、vuex状态管理、数据持久化、用户信息加密等方面进行介绍和记录;
- 这也是我边学习边实践的过程,此次记录一是方便自己日后开发过程中有用到时候便于借鉴和复习,再次是为了初学vue3和尝试上手vite2和vue3搭建管理系统的小伙伴提供一些学习方法和技术点;
- 本Vue后台管理系统使用的技术点主要有:vite2、vue3、vue-router4.x、vuex4.x、vuex-persistedstate(vuex数据持久化)、Element Plus等。
用户登录
登录简介
登录页面采用的是一级录用,与控制台的路由同级,这样写便于对vue-router路由权限校验的控制;
在vue2中我们频繁使用 this 来处理事件函数和组件数据,vue3大多事件函数和数据状态的存储基本都实在setup函数中完成的,在vue3中无法通过 this来获取当前组件的实例,故无法像vue2中那样操作数据和事件函数;
vue3中为了获取到当前组件的实例,我们可以采用 vue3中提供的 getCurrentInstance来获取组件的实例;
当我们使用全局对象或者函数时,我们大多是将事件函数绑定在vue的原型实例上,当再次访问时只需使用过this来访问自己指定的事件名即可;
在vue3中我们若是使用全局变量或者事件函数时,我们需要借助 globalProperties来实现全局事件函数的绑定;此时在需要使用的地方可以通过当前组件实例来访问全局的 property属性;
对登录用的的信息进行加密处理,我采用的是 js-base64 的 encode 方法来实现登录信息的加密。使用方式为:encode(“需要加密的JSON字符串”)。
登录页面代码
<template>
<div class="login">
<el-card class="login_center">
<template #header>
<div class="card_header">
<span>用户登录</span>
</div>
</template>
<el-form :model="loginFormState" :rules="rules" ref="loginFormRef">
<el-form-item prop="name">
<el-input
prefix-icon="el-icon-user-solid"
v-model.trim="loginFormState.name"
maxlength="32"
placeholder="请输入账号"
clearable
></el-input>
</el-form-item>
<el-form-item prop="pwd">
<el-input
prefix-icon="el-icon-lock"
v-model.trim="loginFormState.pwd"
maxlength="16"
show-password
placeholder="请输入密码"
clearable
@keyup.enter.exact="handleLogin"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%" :loading="loginFormState.loading" @click="handleLogin">登 录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
登录逻辑代码
import { getCurrentInstance, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { encode } from "js-base64";
export default {
setup() {
const { proxy } = getCurrentInstance();
const router = useRouter();
const store = useStore();
const loginFormRef = ref();
const loginFormState = reactive({
name: "",
pwd: "",
loading: false
});
const rules = {
name: [{ required: true, message: "账号不能为空", trigger: "blur" }],
pwd: [
{ required: true, message: "密码不能为空", trigger: "blur" },
{ min: 5, max: 16, message: "密码长度为5-16位", trigger: "blur" }
]
};
const handleLogin = () => {
loginFormRef.value.validate(valid => {
if (!valid) {
return false;
}
loginFormState.loading = true;
let params = { name: loginFormState.name, pwd: loginFormState.pwd };
setTimeout(() => {
let users = { role: loginFormState.name === "admin" ? "admin" : "", username: loginFormState.name };
Object.assign(params, users);
sessionStorage.setItem("jwt", encode(JSON.stringify(params)));
store.dispatch("setUser", params);
loginFormState.loading = false;
router.replace("/");
}, 1000);
// proxy.$axios
// .post("/user/login", proxy.$qs.stringify(params))
// .then(res => {
// let { code, result_data, message } = res.data;
// if (code == 1) {
// console.log("login_success", result_data);
// ElMessage.success("登录成功");
// } else {
// ElMessage.error("登录失败:" + message);
// }
// })
// .catch(err => {
// console.log("login err", err);
// ElMessage.error("登录失败");
// });
});
};
return { loginFormRef, loginFormState, rules, handleLogin };
}
};
系统主页
Layout布局代码
<template>
<el-header height="56px">
<!-- header -->
<div class="header_left">Element-Plus Create By Vite</div>
<div class="header_right">
<!-- 退出全屏、进入全屏按钮 -->
<el-tooltip :content="isFullScreen ? '退出全屏' : '全屏'">
<i class="el-icon-full-screen" @click="handleFullScreen"></i>
</el-tooltip>
<!-- 下拉菜单 -->
<el-dropdown size="medium" @command="handleCommand">
<!-- 用户信息 -->
<div class="user_info">
<!-- 用户头像 -->
<el-avatar :size="36" :src="avatar" />
<!-- 用户名宁 -->
<span class="username">{{ userName }}</span>
</div>
<template #dropdown>
<!-- 折叠菜单 -->
<el-dropdown-menu>
<el-dropdown-item icon="el-icon-user" command="user">个人中心</el-dropdown-item>
<el-dropdown-item icon="el-icon-switch-button" command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
</template>
<!-- 二级路由公用路由页面 -->
<template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</template>
主页Header相关逻辑
import { computed, getCurrentInstance, reactive, toRefs } from "vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import screenfull from "screenfull";
import avatar from "@/assets/img/admin.png";
export default {
setup() {
const { proxy } = getCurrentInstance();
const router = useRouter();
const store = useStore();
const state = reactive({
isFullScreen: false,
avatar,
screenfull
});
const userName = computed(() => store.getters.getUserName);
const handleCommand = command => {
if (command === "user") {
router.push("/user");
} else {
proxy.$message.success("退出成功");
store.dispatch("clearUser");
router.replace("/login");
sessionStorage.clear();
localStorage.clear();
}
};
const handleFullScreen = () => {
if (screenfull.isEnabled) {
state.isFullScreen = !state.isFullScreen;
screenfull.toggle();
}
};
return {
userName,
handleCommand,
handleFullScreen,
...toRefs(state)
};
}
};
- Header分左右两部分,其中左侧为系统的名字,右侧为用户登录的账户相关的信息以及进入和退出全屏的按钮;
- 不同用户权限会对应不同的账户头像,会对不同账户的用户权限做相应的限制处理;
全屏的切换借助的是第三方的插件进行处理的,此方式减少代码量的同时也减少了不同浏览器兼容性问题的出现; - 退出账户逻辑的处理,当用户点击退出账户的时候进行相应的退出登录的弹窗提示,在退出后进行数据的初始化和本地存储信息的清除处理,并跳转到用户登录页。
- 主页使用了地图模块,地图模块是借助的“高德地图”API实现的H5版的网页地图,此Demo需要使用注册高德地图开发者来获取开发的keys来创建地图实例;
- 本笔记主要就后台管理系统做笔记分析,高德地图此处不做过多介绍,若想进一步了解,请前往高德开放平台进行了解学习。
数据管理
<template>
<el-card shadow="never" class="index">
<template #header>
<div class="card_header">
<b>数据列表</b>
</div>
</template>
<el-empty description="暂无数据"></el-empty>
</el-card>
</template>
<script></script>
<style lang="scss" scoped>
.card_header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
视频播放器
<template>
<el-card shadow="never" class="index">
<template #header>
<div class="card_header">
<b>🍉西瓜播放器</b>
</div>
</template>
<div id="xg"></div>
</el-card>
</template>
<script>
import { onMounted, onBeforeUnmount, getCurrentInstance, ref } from "vue";
import Player from "xgplayer";
export default {
setup() {
const { proxy } = getCurrentInstance();
let player;
onMounted(() => {
initPlayer();
});
onBeforeUnmount(() => {
player.destroy();
player = null;
});
const initPlayer = () => {
player = new Player({
id: "xg",
url: "https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-360p.mp4",
poster: "https://img03.sogoucdn.com/app/a/07/f13b5c3830f02b6db698a2ae43ff6a67",
fitVideoSize: "auto",
fluid: true /* 流式布局 */,
// download: true /* 视频下载 */
// pip: true /* 画中画 */,
// errorTips: `请<span>刷新页面</span>试试` /* 自定义错误提示 */,
lang: "zh-cn"
});
};
return {};
}
};
</script>
<style lang="scss" scoped>
.card_header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
- 安装西瓜视频播放器:
yarn add xgplayer
- 西瓜播放器官方文档:http://v2.h5player.bytedance.com/
- 西瓜播放器适合手机版和PC电脑版视频点播或直播使用,详细参数配置请参考官方文档。
富文本编辑器
富文本编辑器插件封装
<template>
<div ref="editor" class="editor_ref"></div>
</template>
<script>
import { onMounted, onBeforeUnmount, watch, getCurrentInstance, ref } from "vue";
import WEditor from "wangeditor";
export default {
props: {
defaultText: { type: String, default: "" }
},
setup(props, context) {
const { proxy } = getCurrentInstance();
const editor = ref();
let instance;
onMounted(() => {
initEditor();
});
onBeforeUnmount(() => {
instance.destroy();
instance = null;
});
watch(
() => props.defaultText,
nv => {
instance.txt.html(nv);
!!nv && context.emit("richHtml", nv);
}
);
const initEditor = () => {
instance = new WEditor(editor.value);
// 配置富文本
Object.assign(instance.config, {
zIndex: 100,
// placeholder: "" /* 提示文字 */,
showFullScreen: true /* 显示全屏按钮 */,
showLinkImg: true /* 显示插入网络图片 */,
showLinkVideo: true /* 显示插入网络视频 */,
onchangeTimeout: 400 /* 触发 onchange 的时间频率,默认 200ms */,
uploadImgMaxLength: 1 /* 单次上传图片数量限制 */,
uploadImgMaxSize: 5 * 1024 * 1024 /* 上传图片大小限制 */,
uploadVideoAccept: ["mp4", "mov"] /* 上传视频格式限制 */,
uploadVideoMaxSize: 1024 * 1024 * 1024 /* 上传视频大小限制1024m */,
excludeMenus: ["strikeThrough", "todo", "code"] /* 移除系统菜单 */,
customAlert(msg, type) {
type == "success" ? proxy.$message.success(msg) : proxy.$message.error(msg);
},
customUploadImg(resultFiles, insertImgFn) {
/**
* @param {Object} file - 文件对象
* @param {String} rootPath - 文件根路径(默认为空、例:“filepath/”)
* @param {Array} fileType - 文件类型限制(默认 [] 不限制,例:['.png','.jpeg'])
* @param {Number} size - 文件大小限制(单位:兆、默认 0 不限制、例:1)
**/
proxy.$oss(resultFiles[0]).then(imgUrl => !!imgUrl && insertImgFn(imgUrl));
},
customUploadVideo(resultFiles, insertVideoFn) {
proxy.$oss(resultFiles[0]).then(videoUrl => !!videoUrl && insertVideoFn(videoUrl)); /* 参数同上 */
},
onchange(nv) {
context.emit("richHtml", nv);
}
});
instance.create();
};
return { editor };
}
};
</script>
<style scoped>
div.editor_ref :deep(iframe) {
max-width: 100%;
max-height: auto;
width: 360px;
height: 180px;
}
</style>
组件内使用
<template>
<el-card shadow="never" class="index">
<template #header>
<div class="card_header">
<b>富文本编辑器</b>
</div>
</template>
<!-- 富文本 -->
<WEditor :defaultText="defaultText" @richHtml="getRichHtml" />
</el-card>
</template>
<script>
import { onMounted, ref } from "vue";
import WEditor from "../../components/WEditor.vue";
export default {
components: { WEditor },
setup() {
const defaultText = ref("");
const richText = ref("");
onMounted(() => {
// 初始化数据
defaultText.value = "<h1>Editor</h1>";
});
const getRichHtml = nv => {
richText.value = nv;
};
return { defaultText, getRichHtml };
}
};
</script>
- 此次是基于Vue3封装的富文本编辑器,编辑器使用的是开源的富文本编辑器wangeditor;
- 代码块一是基于官方文档和配置信息对富文本编辑器进行的相关配置,其中富文本编辑器使用的ali-OSS的云存储,若想详细了解请参照之前的“阿里云文件直传”博客笔记进行了解和学习;
- ref相当于DOM元素的Id,要保持唯一,若一个页面要使用多个富文本编辑器,请做好区分,以便于区分组件的数据。
个人中心
路由
使用vue-router的动态匹配和动态校验,来实现不同账号不同权限,通过前端来对用户权限进行相应的限制;在一些没有访问权限的路径下访问时给予相应的提示以及后续相应的跳转复原等逻辑操作。用户鉴权,前端可以进行限制,也可以通过后台接口数据进行限制,之前开发过程中遇到过通过后台接口来动态渲染路由的,接下来介绍的是纯前端来做路由访问的限制。
路由配置
import Layout from "../layout/Index.vue";
import RouteView from "../components/RouteView.vue";
const layoutMap = [
{
path: "/",
name: "Index",
meta: { title: "控制台", icon: "home" },
component: () => import("../views/Index.vue")
},
{
path: "/data",
meta: { title: "数据管理", icon: "database" },
component: RouteView,
children: [
{
path: "/data/list",
name: "DataList",
meta: { title: "数据列表", roles: ["admin"] },
component: () => import("../views/data/List.vue")
},
{
path: "/data/table",
name: "DataTable",
meta: { title: "数据表格" },
component: () => import("../views/data/Table.vue")
}
]
},
{
path: "/admin",
meta: { title: "用户管理", icon: "user" },
component: RouteView,
children: [
{
path: "/admin/user",
name: "AdminAuth",
meta: { title: "用户列表", roles: ["admin"] },
component: () => import("../views/admin/AuthList.vue")
},
{
path: "/admin/role",
name: "AdminRole",
meta: { title: "角色列表" },
component: () => import("../views/admin/RoleList.vue")
}
]
},
{
path: "user",
name: "User",
hidden: true /* 不在侧边导航展示 */,
meta: { title: "个人中心" },
component: () => import("../views/admin/User.vue")
},
{
path: "/error",
name: "NotFound",
hidden: true,
meta: { title: "Not Found" },
component: () => import("../components/NotFound.vue")
}
];
const routes = [
{
path: "/login",
name: "Login",
meta: { title: "用户登录" },
component: () => import("../views/Login.vue")
},
{
path: "/",
component: Layout,
children: [...layoutMap]
},
{ path: "/*", redirect: { name: "NotFound" } }
];
export { routes, layoutMap };
- 此次路由列表分为两部分,其中一部分是默认路由,即无需权限校验的路由路径(如:Login登录页);
- 其中layoutMap中的路由元素是全部与路
- 由路径相关的配置信息,即包裹所有用户权限的路径路由信息;
- 路由鉴权最终限制的就是layoutMap数组中的数据元素,并且进行相应的筛选限制来达到限制路由访问的目的。
路由拦截
// vue-router4.0版写法
import { createRouter, createWebHistory } from "vue-router";
import { decode } from "js-base64";
import { routes } from "./router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
NProgress.configure({ showSpinner: false });
const router = createRouter({
history: createWebHistory(),
routes: [...routes],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
} else {
return { top: 0 };
}
}
});
router.beforeEach((to, from, next) => {
NProgress.start();
const jwt = sessionStorage.getItem("jwt") || "";
document.title = jwt ? (to.meta.title ? to.meta.title + " - 管理应用" : "管理系统") : "系统登录";
if (to.path === "/login") {
!!jwt ? next("/") : next();
} else {
if (from.path === "/login" && !jwt) {
NProgress.done(true);
next(false);
return;
}
if (!!jwt) {
if (to.meta.hasOwnProperty("roles")) {
let roles = to.meta.roles || [],
{ role } = jwt && JSON.parse(decode(jwt));
roles.includes(role) ? next() : next("/error");
return;
}
next();
} else {
next("/login");
}
}
});
router.afterEach(() => {
NProgress.done();
});
export default router;
- 依据访问的路由节点的信息,进行动态的路由权限校验,有访问权限的放过,没有访问权限的路由进行相应的拦截处理;
- nprogress为路由访问的进度条,访问时有相应的进度条指示,也有转动的小菊花(即路由加载指示器)可通过相关配置进行相关的配置;
- 当有用户信息时访问“/login”时则默认重定向到系统控制台页,反之则不进行拦截,让其跳转至登录页面;
- 当访问非登录页面时,要进行role管理员权限的校验,有权限则放过,继续向后执行,反之则重定向到“/error”页面提示其无权访问当前路径。
路由过滤
/* 处理权限 */
export const hasPermission = (route, role) => {
if (route["meta"] && route.meta.hasOwnProperty("roles")) {
return route.meta.roles.includes(role);
}
return true;
};
/* 过滤数组 */
export const filterAsyncRouter = (routers, role) => {
let tmp = [];
tmp = routers.filter(el => {
if (hasPermission(el, role)) {
if (el["children"] && el.children.length) {
el.children = filterAsyncRouter(el.children, role);
}
return true;
}
return false;
});
return tmp;
};
此两函数为封装的过滤指定权限的路由数据,返回过滤后的数据(即当前账号有权访问的页面);
vuex存储和过滤路由信息
import Vue from "vue";
import Vuex from "vuex";
import { layoutMap } from "../router/router";
import { filterAsyncRouter } from "../utils/tool";
import createPersistedState from "vuex-persistedstate";
import SecureLS from "secure-ls";
import { CLEAR_USER, SET_USER, SET_ROUTES } from "./mutation-types";
Vue.use(Vuex);
const state = {
users: null,
routers: []
};
const getters = {};
const mutations = {
[CLEAR_USER](state) {
state.users = null;
state.routers.length = 0;
},
[SET_USER](state, payload) {
state.users = payload;
},
[SET_ROUTES](state, payload) {
state.routers = payload;
}
};
const ls = new SecureLS({
encodingType: "aes" /* 加密方式 */,
isCompression: false /* 压缩数据 */,
encryptionSecret: "vue" /* 加密密钥 */
});
const actions = {
clearUser({ commit }) {
commit(CLEAR_USER);
},
setUser({ commit }, payload) {
let deepCopy = JSON.parse(JSON.stringify(layoutMap)),
accessedRouters = filterAsyncRouter(deepCopy, payload.role);
commit(SET_USER, payload);
commit(SET_ROUTES, accessedRouters);
}
};
const myPersistedState = createPersistedState({
key: "store",
storage: window.sessionStorage,
// storage: {
// getItem: state => ls.get(state),
// setItem: (state, value) => ls.set(state, value),
// removeItem: state => ls.remove(state)
// } /* 永久存储 */
reducer(state) {
return { ...state };
}
});
export default new Vuex.Store({
state,
getters,
mutations,
actions
// plugins: [myPersistedState]
});
- secure-ls 为加密工具函数,加密级别比较高,一般不可破解,基于密钥和私钥进行加密和解密,使用规则请参考github;
- vuex-persistedstate 为持久化处理vuex状态使用的,存储方式主要有sessionStorage、localStorage以cookies,一般常用前两种方式;
- 借助vuex来遍历过滤指定权限的路由,然后在Menu.vue中进行渲染和遍历。
路由列表渲染
<template>
<a-layout-sider class="sider" v-model="collapsed" collapsible :collapsedWidth="56">
<div class="logo">
<a-icon type="ant-design" />
</div>
<a-menu
class="menu"
theme="dark"
mode="inline"
:defaultOpenKeys="[defaultOpenKeys]"
:selectedKeys="[$route.path]"
:inlineIndent="16"
>
<template v-for="route in routers">
<template v-if="!route['hidden']">
<a-sub-menu v-if="route.children && route.children.length" :key="route.path">
<span slot="title">
<a-icon :type="route.meta['icon']" />
<span>{{ route.meta.title }}</span>
</span>
<a-menu-item v-for="sub in route.children" :key="sub.path">
<router-link :to="{ path: sub.path }">
<a-icon v-if="sub.meta['icon']" :type="sub.meta['icon']" />
<span>{{ sub.meta.title }}</span>
</router-link>
</a-menu-item>
</a-sub-menu>
<a-menu-item v-else :key="route.path">
<router-link :to="{ path: route.path }">
<a-icon :type="route.meta['icon']" />
<span>{{ route.meta.title }}</span>
</router-link>
</a-menu-item>
</template>
</template>
</a-menu>
</a-layout-sider>
</template>
<script>
import { mapState } from "vuex";
export default {
name: "Sider",
data() {
return {
collapsed: false,
defaultOpenKeys: ""
};
},
computed: {
...mapState(["routers"])
},
created() {
this.defaultOpenKeys = "/" + this.$route.path.split("/")[1];
}
};
</script>
<style lang="less" scoped>
.sider {
height: 100vh;
overflow: hidden;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
.logo {
height: 56px;
line-height: 56px;
font-size: 30px;
color: #fff;
text-align: center;
background-color: #002140;
}
.menu {
width: auto;
}
}
</style>
<style>
ul.ant-menu-inline-collapsed > li.ant-menu-item,
ul.ant-menu-inline-collapsed > li.ant-menu-submenu > div.ant-menu-submenu-title {
padding: 0 16px !important;
text-align: center;
}
</style>
该菜单渲染是基于Vue2.x和Ant Design Vue来编辑实现的。
<template>
<el-aside :width="isCollapse ? `64px` : `200px`">
<div class="logo">
<img src="@/assets/img/avatar.png" alt="logo" draggable="false" />
<p>Vite2 Admin</p>
</div>
<el-menu
background-color="#001529"
text-color="#eee"
active-text-color="#fff"
router
unique-opened
:default-active="route.path"
:collapse="isCollapse"
>
<template v-for="item in routers" :key="item.name">
<template v-if="!item['hidden']">
<el-submenu v-if="item.children && item.children.length" :index="concatPath(item.path)">
<template #title>
<i :class="item.meta.icon"></i>
<span>{{ item.meta.title }}</span>
</template>
<template v-for="sub in item.children" :key="sub.name">
<el-menu-item :index="concatPath(item.path, sub.path)">
<i :class="sub.meta['icon']"></i>
<template #title>{{ sub.meta.title }}</template>
</el-menu-item>
</template>
</el-submenu>
<el-menu-item v-else :index="concatPath(item.path)">
<i :class="item.meta['icon']"></i>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</template>
</el-menu>
<div class="fold" @click="changeCollapse">
<i v-show="!isCollapse" class="el-icon-d-arrow-left"></i>
<i v-show="isCollapse" class="el-icon-d-arrow-right"></i>
</div>
</el-aside>
</template>
<script>
import { computed, reactive, toRefs } from "vue";
import { useRoute } from "vue-router";
import { useStore } from "vuex";
export default {
setup() {
const route = useRoute();
const store = useStore();
const state = reactive({ isCollapse: false });
const routers = computed(() => store.state.routers);
const changeCollapse = () => {
state.isCollapse = !state.isCollapse;
};
const concatPath = (p_path, c_path = "") => {
return `${p_path !== "" ? "/" + p_path : "/"}${c_path !== "" ? "/" + c_path : ""}`;
};
return {
route,
routers,
concatPath,
changeCollapse,
...toRefs(state)
};
}
};
</script>
- 该菜单导航是基于vue3和支持Vue3版本的Element-Plus实现的,详细参数配置请参考Element-plus官网;
- 此处获取的路由数组即鉴权过滤后的路由数组数据;此菜单将会依据登录信息动态遍历生成指定菜单数据。
总结
文章主要介绍使用element-plus进行页面的布局和数据展示处理,完整的搭建出一个带有前端权限校验的vue后台管理系统,主要是梳理清路由数据和过滤后的路由鉴权后的路由数据信息。主要代码就是上述封装的过滤和权限校验函数。