Vue管理系統前端系列六動態路由-許可權管理實現

發表於2020-08-22


為什麼要使用動態路由?

一般系統中,都會有不同許可權的操作人員,這時候他們看到的頁面也將會不同,因此都需要根據他們的許可權來生成對應的選單,這個就得通過動態路由來實現。

主流的兩種實現方式

控制一般都是由前端的路由中設定。後端返回路由表動態生成兩種。

本文主要記錄由資料庫維護的動態路由實現,和相關注意點。即 退出 和 重新整理 相關點導致的路由問題。

其它的可參考文章:動態路由

前端控制

  • 不用後端控制,只用返回角色、
  • 根據可能有的角色,在對應路由上維護相關角色
  • 在登入後,判斷路由中維護的角色是否吻合來動態新增生成

後端控制

  • 路由存在資料庫中,可動態維護。且相對安全。
  • 登入後,獲取動態路由資訊。
  • 得到路由後,匹配檔案,生成路由,新增

後端控制路由 實現

由於我這裡是用 fastmock 模擬的資料,實際中還請自行生成。

mock 資料如下:

{
    "code": 200,
    "success": true,
    "data": [
        {
            "menuId": 2,
            "menuName": "一級選單",
            "parentMenuId": 0,
            "url": "menu/singleMenu/index",
            "type": 1,
            "icon": "el-icon-wind-power",
            "orderIndex": 1,
            "children": [
            ]
        },
        {
            "menuId": 3,
            "menuName": "二級選單",
            "parentMenuId": 0,
            "url": "",
            "type": 1,
            "icon": "el-icon-ice-cream-round",
            "orderIndex": 1,
            "children": [
                {
                    "menuId": 301,
                    "menuName": "二級1-1",
                    "parentMenuId": 3,
                    "url": "menu/secondMenu/second1-1",
                    "type": 2,
                    "icon": "el-icon-milk-tea",
                    "orderIndex": 1,
                    "children": [
                    ]
                },
                {
                    "menuId": 302,
                    "menuName": "二級1-2",
                    "parentMenuId": 3,
                    "url": "menu/secondMenu/second1-2",
                    "type": 2,
                    "icon": "el-icon-potato-strips",
                    "orderIndex": 2,
                    "children": [
                    ]
                },
                {
                    "menuId": 303,
                    "menuName": "二級1-3",
                    "parentMenuId": 3,
                    "url": "menu/secondMenu/second1-3",
                    "type": 2,
                    "icon": "el-icon-lollipop",
                    "orderIndex": 3,
                    "children": [
                    ]
                }
            ]
        },
        {
            "menuId": 4,
            "menuName": "三級多級選單",
            "parentMenuId": 0,
            "url": "",
            "type": 1,
            "icon": "el-icon-ice-cream-round",
            "orderIndex": 1,
            "children": [
                {
                    "menuId": 401,
                    "menuName": "三級1-1",
                    "parentMenuId": 4,
                    "url": "menu/threeMenu/three1-1",
                    "type": 2,
                    "icon": "el-icon-milk-tea",
                    "orderIndex": 1,
                    "children": [
                    ]
                },
                {
                    "menuId": 402,
                    "menuName": "二級1-2",
                    "parentMenuId": 4,
                    "url": "",
                    "type": 2,
                    "icon": "el-icon-potato-strips",
                    "orderIndex": 2,
                    "children": [
                        {
                            "menuId": 40201,
                            "menuName": "三級1-2-1",
                            "parentMenuId": 402,
                            "url": "menu/threeMenu/nextMenu/three1-2-1",
                            "type": 2,
                            "icon": "el-icon-milk-tea",
                            "orderIndex": 1,
                            "children": [
                            ]
                        },
                        {
                            "menuId": 40202,
                            "menuName": "三級1-2-2",
                            "parentMenuId": 402,
                            "url": "menu/threeMenu/nextMenu/three1-2-2",
                            "type": 2,
                            "icon": "el-icon-potato-strips",
                            "orderIndex": 2,
                            "children": [
                            ]
                        }
                    ]
                }
            ]
        }
    ],
    "message": "成功"
}

新增選單介面 及 選單狀態管理

由於這裡是 mock 的。所以就判斷了下登入使用者名稱。程式碼如下:

export const getMenu = (username) => {
    if (username == 'user') {
        return axios.Get('api/usermenu')
    } else {
        return axios.Get('api/menu')
    }
}

狀態管理用於儲存當前理由載入狀態,和選單值。

再在 actions 中新增一個獲取選單的方法,完整程式碼如下:

//引入介面
import { getMenu } from '@/api/modules/system'

export default {
    state: {
        menuTree: [],
        menuLoad: false, //選單是否已載入狀態避免重複載入,重新整理又將變為false。
    },
    getters: {
        menuLoad: (state) => {
            return state.menuLoad
        },
    },
    mutations: {
        setMenuLoad(state, menuLoad) {
            state.menuLoad = menuLoad
        },
        setMenuTree(state, menuTree) {
            state.menuTree = menuTree
        },
    },
    actions: {
        getMenuTree({ commit }, username) {
            return new Promise((resolve, reject) => {
                getMenu(username)
                    .then((res) => {
                        if (res.code === 200) {
                            if (res.success) {
                                commit('setMenuTree', res.data)
                            } else {
                                // TODO 處理錯誤訊息
                            }
                            resolve(res.data)
                        }
                    })
                    .catch((error) => {
                        reject(error)
                    })
            })
        },
    },
}

根據得到的選單生成動態路由

在這裡由於退出時,會導致路由和載入狀態不會更新,也不會重置路由的原因,完整程式碼中包含相關處理。

import Vue from 'vue'
import VueRouter from 'vue-router'
import login from '@/views/login'
import store from '@/store'
import { getMenu } from '@/api/modules/system'

Vue.use(VueRouter)
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
    return originalPush.call(this, location).catch((err) => err)
}
const routes = [
    {
        path: '/',
        name: 'home',
        component: () => import('@/layout'),
        children: [
            {
                path: '',
                name: 'index',
                component: () => import('@/views/home/defaultPage'),
                meta: {
                    title: '首頁',
                    index: 0,
                },
            },
        ],
    },
    {
        path: '/login',
        name: 'login',
        component: login,
        meta: {
            title: '登入',
        },
    },
    {
        path: '/notfound',
        name: 'notfound',
        component: () => import('@/views/notfound'),
        meta: {
            title: '未找到',
        },
    },
]

const defultRouter = () => {
    return new VueRouter({
        routes: routes,
    })
}
//每次使用預設路由
const router = defultRouter()

// 解決addRoute不能刪除動態路由問題
export function resetRouter() {
    const reset = defultRouter()
    router.matcher = reset.matcher
}

const WhiteListRouter = ['/login', '/notfound'] // 路由白名單
//導航守衛  路由開始前
router.beforeEach(async (to, from, next) => {
    let user = store.getters.userInfo
    let token = store.getters.token
    var hasAuth = user !== null && token !== null && user !== undefined && token !== undefined
    if (to.path == '/login') {
        if (hasAuth) {
            next({ path: '/' })
        } else {
            next()
        }
    } else {
        if (!hasAuth) {
            //沒登入的情況下  訪問的是否是白名單
            if (WhiteListRouter.indexOf(to.path) !== -1) {
                next()
            } else {
                next({
                    path: '/login',
                    query: {
                        redirect: to.fullPath,
                    },
                })
            }
        } else {
            if (store.state.app.menuLoad) {
                // 已經載入過路由了
                next()
                return
            } else {
                console.log(user.username)
                // 使用 await 進行同步處理
                const menu = await store.dispatch('getMenuTree', user.username)
                console.log(menu)
                // 載入動態選單和路由
                addDynamicMenuRoute(menu)
                //next({ ...to, replace: true }); // hack方法 確保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
                next()
            }
        }
    }
})

//重新整理 載入完後 載入未找到路由  此方法只會在重新整理後載入一次
router.onReady(() => {
    var notfund = [
        {
            path: '*',
            redirect: '/notfound',
            name: 'notfound',
            component: () => import('@/views/notfound'),
            meta: {
                title: '未找到',
            },
        },
    ]
    router.options.routes = router.options.routes.concat(notfund)
    router.addRoutes(router.options.routes)
})

/**
 * 載入動態選單和路由
 */
function addDynamicMenuRoute(menuData) {
    if (store.state.app.menuRouteLoaded) {
        console.log('已載入選單和路由.')
        return
    }
    // 根據返回的選單 拼裝路由模組
    let dynamicRoutes = addDynamicRoutes(menuData)
    // 處理靜態元件繫結路由
    router.options.routes[0].children = router.options.routes[0].children.concat(dynamicRoutes)

    //新增路由
    router.addRoutes(router.options.routes)

    // 儲存載入狀態
    store.commit('setMenuLoad', true)
}
/**
 * 新增動態(選單)路由
 * @param {*} menuList 選單列表
 * @param {*} routes 遞迴建立的動態(選單)路由
 */
function addDynamicRoutes(menuList = [], routes = []) {
    var temp = []
    for (var i = 0; i < menuList.length; i++) {
        if (menuList[i].children && menuList[i].children.length >= 1) {
            temp = temp.concat(menuList[i].children)
        } else if (menuList[i].url && /\S/.test(menuList[i].url)) {
            //將第一個斜槓去掉
            menuList[i].url = menuList[i].url.replace(/^\//, '')
            // 建立路由配置
            var route = {
                path: menuList[i].url,
                component: null,
                name: menuList[i].menuName,
                meta: {
                    title: menuList[i].menuName,
                    icon: menuList[i].icon,
                    index: menuList[i].menuId,
                },
            }
            try {
                // 根據選單URL動態載入vue元件,這裡要求vue元件須按照url路徑儲存
                // 如url="menu/singleMenu/index",則元件路徑應是"@/views/menu/singleMenu/index".vue",否則將找不到改元件
                let url = menuList[i].url
                route['component'] = (resolve) => require([`@/views/${url}`], resolve)
            } catch (e) {}

            routes.push(route)
        }
    }
    if (temp.length >= 1) {
        addDynamicRoutes(temp, routes)
    }
    return routes
}

export default router

根據 vuex 中的暫存的選單生成側邊選單欄

新建選單元件 遞迴生成選單,新建 menuTree/index.vue,程式碼如下:

<!-- 動態選單組成 -->
<template>
    <div>
        <!-- :popper-append-to-body="false" 解決 el-menu垂直佈局遞迴生成選單及選單摺疊後hover報錯Maximum call stack size exceeded -->
        <!-- 但是會導致最上面的選單嵌入頁面,無法顯示出來  因此使用外層巢狀div加樣式的解決方案-->
        <el-submenu v-if="menu.children && menu.children.length >= 1"
                    :index="'' + menu.menuId"
                    :popper-append-to-body="true">
            <template slot="title">
                <i :class="menu.icon"></i>
                <span slot="title">{{ menu.menuName }}
                </span>
            </template>
            <menu-tree v-for="item in menu.children"
                       :key="item.menuId"
                       :menu="item">
            </menu-tree>
        </el-submenu>
        <el-menu-item v-else-if="menu.url == ''"
                      :index="'' + menu.menuId"
                      disabled>
            <i class="el-icon-magic-stick"></i>
            <span slot="title">{{ menu.menuName }}
            </span>
        </el-menu-item>
        <el-menu-item v-else
                      :index="'' + menu.menuId"
                      @click="$router.push('/' + menu.url)">
            <i :class="menu.icon"></i>
            <span slot="title">{{ menu.menuName }}
            </span>
        </el-menu-item>
    </div>
</template>

<script>
export default {
    name: "MenuTree",
    props: {
        menu: {
            type: Object,
            required: true
        }
    },
    data () {
        return {}
    },

}
</script>
<style lang="scss" scoped>
.el-menu--collapse > div > .el-submenu > .el-submenu__title span {
    height: 0;
    width: 0;
    overflow: hidden;
    visibility: hidden;
    display: inline-block;
}
.el-menu--collapse
    > div
    > .el-submenu
    > .el-submenu__title
    .el-submenu__icon-arrow {
    display: none;
}
</style>

在側邊欄中,從 state 中得到選單,生成側邊欄選單,完整程式碼如下:

<!-- aside -->
<template>
    <div class="aside-container"
         :class="isCollapse ? 'aside-collapse-width' : 'aside-width'">
        <!--導航選單  default-active="1-1"  :background-color="themeColor" -->
        <el-menu class="el-menu-vertical-demo"
                 :class="isCollapse ? 'aside-collapse-width' : 'aside-width'"
                 :collapse-transition="false"
                 :unique-opened="true"
                 :collapse="isCollapse"
                 ref="menuTreeRef"
                 :background-color="themeColor"
                 text-color="#fff"
                 active-text-color="#ffd04b">
            <menu-tree v-for="menu in menuTree"
                       :key="menu.menuId"
                       :menu="menu">
            </menu-tree>
        </el-menu>
    </div>
</template>

<script>
import { mapState } from 'vuex'
import MenuTree from './menuTree'
export default {
    data () {
        return {}
    },
    components: {
        MenuTree,
    },
    computed: {
        ...mapState({
            isCollapse: (state) => state.app.isCollapse,
            themeColor: (state) => state.app.themeColor,
            menuTree: (state) => state.app.menuTree,
        }),
        mainTabs: {
            get () {
                return this.$store.state.app.mainTabs
            },
            set (val) {
                this.$store.commit('updateMainTabs', val)
            },
        },
        mainTabsActiveName: {
            get () {
                return this.$store.state.app.mainTabsActiveName
            },
            set (val) {
                this.$store.commit('updateMainTabsActiveName', val)
            },
        },
    },
    watch: {
        $route: 'handleRoute',
    },
    created () {
        console.log(this.$route)
        this.handleRoute(this.$route)
    },
    methods: {
        // 路由操作處理
        handleRoute (route) {
            // tab標籤頁選中, 如果不存在則先新增
            var tab = this.mainTabs.filter((item) => item.name === route.name)[0]
            if (!tab) {
                tab = {
                    name: route.name,
                    title: route.meta.title,
                    icon: route.meta.icon,
                }
                this.mainTabs = this.mainTabs.concat(tab)
            }
            this.mainTabsActiveName = tab.name
            //解決重新整理後,無法定位到當前開啟選單欄
            this.$nextTick(() => {
                // 切換標籤頁時同步更新高亮選單
                if (this.$refs.menuTreeRef != null) {
                    this.$refs.menuTreeRef.activeIndex = '' + route.meta.index
                    this.$refs.menuTreeRef.initOpenedMenu()
                }
            })
        },
    },
}
</script>
<style lang="scss" scoped>
.aside-container {
    position: fixed;
    top: 0px;
    left: 0;
    bottom: 0;
    z-index: 1020;
    .el-menu {
        position: absolute;
        top: $header-height;
        bottom: 0px;
        text-align: left;
    }
}
.aside-width {
    width: $aside-width;
}
.aside-collapse-width {
    width: $aside-collapse-width;
}
</style>

退出後重置 vuex

因為只要登入過,那麼當前狀態中的 活動窗體 肯定是有值的,那麼只需要判斷該值是否有,有就重新整理一下介面。

這裡使用的是 reload 來重新整理頁面。

    created() {
        //若是使用狀態退出 則重新整理一下 重置vuex
        if (this.$store.state.app.mainTabsActiveName != '') {
            window.location.reload()
        }
    },

重新整理的方式可以參考:vue 重新整理當前頁的三種方法

最終效果

原文地址:http://book.levy.net.cn/doc/frontend/uiframe/dynamic_router.html

相關文章