VUE3後台管理系統【路由鑒權】

🌏前言:

在「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後台管理系統,主要是梳理清路由數據和過濾後的路由鑒權後的路由數據資訊。主要程式碼就是上述封裝的過濾和許可權校驗函數。後續將放開後台模板程式碼,模板程式碼完善中……🍎🍎🍎

Tags: