VUE3後臺管理系統【路由鑑權】

下落香樟樹發表於2021-06-08

?前言:

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

相關文章