VUE3后台管理系统【路由鉴权】

5,037 阅读5分钟

🌏前言:

在“VUE3后台管理系统【模板构建】”文章中,详细的介绍了我使用vue3.0和vite2.0构建的后台管理系统,虽然只是简单的一个后台管理系统,其中涉及的技术基本都覆盖了,基于vue3的vue-router和vuex,以及借助第三方开源插件来实现vuex数据持久化。前边只是介绍了vue后台管理系统的页面布局,以及一些常用的插件的使用,如:富文本编辑器、视频播放器、页面滚动条美化(前边忘记介绍了,此次文章中将会进行添加和补充)。 本次文章主要介绍的是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 };
        }
    }
});

// 路由拦截与下方vue-router3.x写法相同
// vue-router3.x版写法
import Vue from "vue";
import VueRouter from "vue-router";
import { decode } from "js-base64";
import { routes } from "./router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";

NProgress.configure({ showSpinner: false });

Vue.use(VueRouter);

const router = new VueRouter({
    mode: "history",
    base: process.env.BASE_URL,
    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官网;
  • 此处获取的路由数组即鉴权过滤后的路由数组数据;此菜单将会依据登录信息动态遍历生成指定菜单数据。

🍌总结:

结合之前的模板代码,就可以完整的搭建出一个带有前端权限校验的vue后台管理系统,主要是梳理清路由数据和过滤后的路由鉴权后的路由数据信息。主要代码就是上述封装的过滤和权限校验函数。后续将放开后台模板代码,模板代码完善中......🍎🍎🍎