乐趣区

关于vue.js:Vue路由权限控制

Vue 路由权限管制

当咱们在做后盾管理系统的时候,都会波及到零碎左侧的菜单树如何动态显示的问题。目前基本上都是 RBAC 的解决方案,即Role-Based Access Control,权限与角色相关联,用户通过成为适当角色的成员而失去这些角色的权限。这就极大地简化了权限的治理。

vue 有很多优良的后盾管理系统模板,这些开源我的项目都提供了 RBAC 权限管制的思路,然而在理论我的项目中,写死角色的形式可能并不适宜。

看了网上蛮多的解决方案,个人感觉都有弊病,好多都是前端先把残缺的路由表注册到我的项目中,而后通过后盾返回树过滤显示的计划,这样的做法其实只是暗藏了左侧菜单,然而路由还是曾经注册进去了,用户猜到拜访门路还是能够轻易进入页面,没有真正的做到 动静路由加载


以下是我设计的解决方案,自己小白,仅供参考 -。-

先看一下曾经实现的系统结构,用户绑定角色(一对多),角色绑定菜单(一对多)

用户菜单

抉择角色

角色菜单

抉择菜单,因为本我的项目是多零碎,所以会有 ADMIN 和 HMI 两个子系统,前面再来解释

资源管理(我这里没有叫做菜单治理,因为会波及到各个子系统我称作模块,模块上面有菜单,菜单上面有按钮)


好了,看完几张图大家预计也明确了这就是典型的 RBAC。外部具体怎么运作的呢

要实现动静增加路由,即只有有权限的路由才会注册到 Vue 实例中,外围是 vue-router 的 addRoutes 和导航钩子 beforeEach 两个办法


大体思路为,在 beforeEach 办法中(即每次路由跳转之前做判断),如果曾经加载了路由表,则把路由表注册到实例中,如果没有,则从后端拉取路由表注册到实例中。那为什么不在登录的时候加载一次就能够了呢?这是因为如果只是登录的时候加载一次,网页刷新的时候注册的路由表会失落,所以咱们对立在 beforeEach 这个钩子中去实现

// permission.js, 此文件在 main.js 中间接导入即可,这边的思路是仿照的 element-admin

import router from './router'
import store from './store'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import Cookies from 'js-cookie'
import screenfull from 'screenfull'

router.beforeEach(async (to, from, next) => {const token = Cookies.get('token')
    NProgress.start()
    if (token) {
        // 如果曾经处于登录状态,跳到登录页重定向到首页
        if (to.path === '/login') {next({ path: '/'})
            NProgress.done()} else {if (!store.state.authorized) {
                try {router.addRoutes(await store.dispatch('setAccessRoutes'))
                    store.dispatch('setAllDict')
                    next({...to, replace: true})
                } catch (e) {Cookies.remove('token')
                    Cookies.remove('userInfo')
                    next({path: '/login'})
                    NProgress.done()}
            } else {next()
                // 全屏参数判断该页面是否全屏
                if (!screenfull.isEnabled) return
                if (to.meta && to.meta.fullScreen) {screenfull.request().catch(() => null)
                } else {if (screenfull.isFullscreen) {screenfull.exit()
                    }
                }
            }
        }
    } else {next(to.path !== '/login' ? { path: '/login'} : true)
    }
})

router.afterEach(() => {NProgress.done()
})

因为路由是动静注册的,所以我的项目的初始路由就会很简洁,只有提供根底的路由,其余路由都是从服务器返回之后动静注册进来的

// router.js

import Vue from 'vue'
import Router from 'vue-router'
import Login from 'modules/Login'
import NoPermission from 'modules/NoPermission'
Vue.use(Router)

// Fixed NavigationDuplicated Problem
const originalPush = Router.prototype.push
Router.prototype.push = function push(location, onComplete, onAbort) {if (onComplete || onAbort) return originalPush.call(this, location, onComplete, onAbort)
    return originalPush.call(this, location).catch(err => err)
}

const createRouter = () =>
    new Router({
        mode: 'history',
        scrollBehavior: () => ({ y: 0}),
        routes: [
            {
                path: '/',
                redirect: '/platform'
            },
            {
                path: '/noPermission',
                component: NoPermission
            },
            {
                path: '/login',
                component: Login
            }
        ]
    })

const router = createRouter()

export function resetRouter() {const newRouter = createRouter()
    router.matcher = newRouter.matcher // reset router
}

export default router

webpack 之前不反对动静编译,所以很多我的项目都在路由表保护了一份映射表如下:

const routerMap = {user: () => import('/views/user'),
    role: () => import('/views/role'),
    ...
}

我感觉这样很不 nice,最新版的 vue-cli 集成的 webpack 曾经能够反对动静导入啦,因而能够把所有的路由信息全副放到数据库外面配置,前端不在须要保护一份 router 的映射关系表啦,如果你是老版的 CLI,能够应用dynamic-import

咱们再来看看上面这个神奇的文件,也能够大抵浏览一下而后看上面的解释

// menu.json

// id: 轻易是什么规定,只有惟一就行,这里前端写死 ID 而不是每次导入数据库时候再生成是因为如果每次入库的时候从新生成会失落之前的关联关系
// title: 菜单的题目
// name: 惟一标识
// type:'MD' 代表模块(子系统),'MN' 代表菜单,'BT' 代表按钮,如果须要管制到按钮权限则须要配置到 BT 级别
// icon: 菜单的图标
// uri: 菜单的路由地址
// componentPath: 该菜单在对应前端我的项目的门路,在后续的 store.js 会看到用法,就是上述说的不须要在写一份 routerMap
// hidden: 作为菜单的时候是否在左侧显示,有些菜单比方某个列表的详情页,须要注册到实例中,然而并不需要在左侧菜单栏显示
// noCache: 因为我的项目页面减少了缓存管制,因而该字段用于判断以后页面是否须要缓存
// fullScreen: 有些菜单,进入的时候就是全屏展现的,例如某些大屏展现页面,通过该字段配置
// children: 和上述字段一样

[
    {
        "id": "00b82eb6e50a45a495df301b0a3cde8b",
        "title": "SV ADMIN",
        "name": "ADMIN",
        "type": "MD",
        "children": [
            {
                {
                "id": "06f1082640a0440b97009d536590cf4f",
                "title": "系统管理",
                "name": "system",
                "icon": "el-icon-setting",
                "uri": "/system",
                "componentPath": "modules/Layout",
                "type": "MN",
                "children": [
                    {
                        "id": "b9bd920263bb47dbbfbf4c6e47cc087b",
                        "title": "用户治理",
                        "name": "principal",
                        "uri": "principal",
                        "componentPath": "views/system/principal",
                        "type": "MN",
                        "children": [{ "id": "b37f971139ca49ab8c6506d4b30eddb3", "title": "新增", "name": "create", "type": "BT"},
                            {"id": "d3bcee30ec03432db9db2da999bb210f", "title": "编辑", "name": "edit", "type": "BT"},
                            {"id": "7c2ce28dcedf439fabc4ae9ad94f6899", "title": "删除", "name": "delete", "type": "BT"},
                            {"id": "bdf4d9e8bf004e40a82b80f0e88c866c", "title": "批改明码", "name": "resetPwd", "type": "BT"},
                            {"id": "ba09f8a270e3420bb8877f8def455f6f", "title": "抉择角色", "name": "setRole", "type": "BT"}
                        ]
                    },
                    {
                        "id": "c47c8ad710774576871739504c6cd2a8",
                        "title": "角色治理",
                        "name": "role",
                        "uri": "role",
                        "componentPath": "views/system/role",
                        "type": "MN",
                        "children": [{ "id": "81c0dca0ed2c455d9e6b6d0c86d24b10", "title": "新增", "name": "create", "type": "BT"},
                            {"id": "19a2bf03e6834d3693d69a70e919d55e", "title": "编辑", "name": "edit", "type": "BT"},
                            {"id": "6136cc46c45a47f4b2f20e899308b097", "title": "删除", "name": "delete", "type": "BT"},
                            {"id": "ad5cf52a78b54a1da7c65be74817744b", "title": "设置菜单", "name": "setMenu", "type": "BT"}
                        ]
                    },
                    {
                        "id": "8b5781640b9b4a5cb28ac616da32636c",
                        "title": "资源管理",
                        "name": "resource",
                        "uri": "resource",
                        "componentPath": "views/system/resource",
                        "type": "MN",
                        "children": [{ "id": "d4182147883f48069173b7d173e821dc", "title": "新增", "name": "create", "type": "BT"},
                            {"id": "935fcb52fffa45acb2891043ddb37ace", "title": "编辑", "name": "edit", "type": "BT"},
                            {"id": "3f99d47b4bfd402eb3c787ee10633f77", "title": "删除", "name": "delete", "type": "BT"}
                        ]
                    }
                ]
            },
            }
        ]
    },
    {
        "id": "fc8194b529fa4e87b454f970a2e71899",
        "title": "SV HMI",
        "name": "HMI",
        "type": "MD",
        "children": [{ "id": "eb5370681213412d8541d171e9929c84", "title": "启动检测","name": "001"},
            {"id": "06eb36e7224043ddbb591eb4d688f438", "title": "设施信息","name": "002"},
            {"id": "76696598fd46432aa19d413bc15b5110", "title": "AI 模型库","name": "003"},
            {"id": "2896f3861d9e4506af8120d6fcb59ee1", "title": "颐养培修","name": "004"},
            {"id": "91825c6d7d7a457ebd70bfdc9a3a2d81", "title": "持续","name": "005"},
            {"id": "24694d28b2c943c88487f6e44e7db626", "title": "暂停","name": "006"},
            {"id": "225387753cf24781bb7c853ee538d087", "title": "完结","name": "007"}
        ]
    }
]

以上是前端的路由配置信息,之前提到过,路由是后端返回的,为什么前端还有一份菜单文件呢?

  • 因为路由外面的内容全部都是前端须要应用的,比方菜单显示的图表,菜单对应的前端门路等等 … 既然和前端关系比拟大,所以前端保护该文件更适宜,而不是让后端去配置 XML 或者 liquibase,你每次菜单有批改的时候要告诉你的后盾要更新一下数据库,而后切换多个环境的时候每个后盾都要告诉一声 … 后盾还不肯定乐意 X 你 … 而后你想改个小图标都要小心翼翼被你的后盾大佬怼 …

Question:

既然前端有该文件,是不是意味着路由的源码又裸露进来了,那和他人的猜门路就能够拜访有什么区别?不是说好了从数据库拉取菜单信息,你跟我间接用这个 json,那数据库咋整?别急 …

Answer:

  1. 这只是前端用来 mock 的 配置 文件,build 的时候不会打包该内容。
  2. 用户角色菜单 这些关联关系还没有建设之前,菜单只能通过 mock 来建设,这个时候间接读取前端的配置文件 … 额,真香,具体是怎么做到的能够看上面 store.js 的做法
  3. 菜单始终由前端来保护,当须要上线的时候,前端能够通过 node 将 menu.json 生成 SQL 语句导入到数据库中。前面会介绍

接下来就是如何注册这些路由表了

// store.js

import Vue from 'vue'
import Vuex from 'vuex'
import Cookie from 'js-cookie'
import NotFound from 'modules/NotFound'
import {resetRouter} from '../router'
import {getUserResourceTree, getDictAllModel} from 'apis'
import {deepClone} from 'utils/tools'

// 此处的 IS_TESTING 就是用来判断以后是拉取数据库的实在菜单还是间接用前端的 menu.json,在资源的关联关系还没有建设之前十分有用
import {IS_TESTING} from '@/config'
import {Message} from 'element-ui'

Vue.use(Vuex)

// 生产可拜访的路由表
const createRouter = (routes, cname = '') => {return routes.reduce((prev, { type, uri: path, componentPath, name, title, icon, redirectUri: redirect, hidden, fullScreen, noCache, children = [] }) => {
        // 是菜单项就注册到路由进去
        if (type === 'MN') {
            prev.push({
                path,
                // 此处就是 webpack 动静导入啦,是不是 so easy,妈妈再用不必放心我再写一份 routerMap 放到源码里了
                component: () => import(`@/${componentPath}`),
                name: (cname + '-' + name).slice(1),
                props: true,
                redirect,
                meta: {title, icon, hidden: hidden === 'Y', type, fullScreen: fullScreen === 'Y', noCache: noCache === 'Y'},
                children: children.length ? createRouter(children, cname + '-' + name) : []})
        }
        return prev
    }, [])
}

// 生产权限按钮表
const createPermissionBtns = router => {let btns = []
    const c = (router, name = '') => {
        router.forEach(v => {v.type === 'BT' && btns.push((name + '-' + v.name).slice(1))
            return v.children && v.children.length ? c(v.children, name + '-' + v.name) : null
        })
        return btns
    }
    return c(router)
}

export default new Vuex.Store({
    state: {
        collapse: false, // 菜单栏是否膨胀
        authorized: false, // 是否拉取了受权菜单
        dict: {},
        accsessRoutes: [], // 已注册的路由
        permissionBtns: [], // 有权限的按钮
        navTags: [], // 标签导航列表
        cachedViews: [] // 缓存的页面},
    getters: {
        collapse: state => state.collapse,
        cachedViews: state => state.cachedViews,
        accsessRoutes: state => state.accsessRoutes,
        // 菜单栏(过滤掉 hidden)
        menuList: state => {
            const filterMenus = menus => {
                return menus.filter(item => {if (item.children && item.children.length) {item.children = filterMenus(item.children)
                    }
                    return item.meta && !item.meta.hidden
                })
            }
            return filterMenus(deepClone(state.accsessRoutes))
        },
        navTags: state => state.navTags
    },
    mutations: {SET_ACCSESS_ROUTES(state, accsessRoutes) {
            state.authorized = true
            state.accsessRoutes = accsessRoutes
        },
        SET_ALL_DICT(state, dict) {state.dict = dict},
        SET_PERMISSION_BTNS(state, btns) {state.permissionBtns = btns},
        SET_COLLAPSE(state, flag) {state.collapse = flag},
        SET_CACHED_VIEWS(state, cachedViews) {state.cachedViews = cachedViews},
        // 退出登录
        LOGOUT: state => {state.cachedViews = []
            state.authorized = false
            resetRouter()
            Cookie.remove('token')
            Cookie.remove('userInfo')
        }
    },
    actions: {setAccessRoutes: ({ commit}) => {return new Promise(async (resolve, reject) => {
                // 404 页面抉择在动静增加路由之后再注册进来,是因为如果开始就注册到我的项目中,在 addRoutes 之后会无限匹配该 404,造成 BUG
                const routerExt = [{ path: '*', redirect: '/404'},
                    {path: '/404', component: NotFound}
                ]
                // getUserResourceTree 这个接口逻辑是查问以后登录人角色所蕴含的资源,过滤出模块名 (这里是 ADMIN) 上面的子节点(蕴含菜单和按钮)const res = await (IS_TESTING ? import('@/mock/menu.json') : getUserResourceTree('ADMIN'))
                if (!res) return reject()
                let router
                if (IS_TESTING) {
                    // 这里取第 0 个是因为我这个零碎是属于大零碎的第一个子系统,在菜单 menu.json 能够看到
                    router = res[0].children
                } else {if (!res.data.length) {reject()
                        return Message.error('用户未配置菜单或菜单配置不正确,请查看后重试~')
                    } else {router = res.data}
                }
                const accessRoutes = createRouter(router).concat(routerExt)
                commit('SET_ACCSESS_ROUTES', accessRoutes)
                commit('SET_PERMISSION_BTNS', createPermissionBtns(router))
                resolve(accessRoutes)
            })
        },
        setAllDict: async ({commit}) => {if (IS_TESTING) return
            const res = await getDictAllModel()
            if (!res) return
            commit('SET_ALL_DICT', res.data)
        },
        logout: ({commit}) => {
            return new Promise(resolve => {commit('LOGOUT')
                resolve()})
        }
    }
})

好了,最初一步就是在上线的时候如何把 menu.json 变成数据库的 SQL,之后就能够把 IS_TESTING 改为 false,真正拉取数据库的菜单啦

// createMenu.js
const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const resolve = dir => path.join(__dirname, dir)
const format = (data = new Date(), fmt = 'yyyy-MM-dd') => {
    let o = {'M+': data.getMonth() + 1, // 月份
        'd+': data.getDate(), // 日
        'h+': data.getHours(), // 小时
        'm+': data.getMinutes(), // 分
        's+': data.getSeconds(), // 秒
        'q+': Math.floor((data.getMonth() + 3) / 3), // 季度
        S: data.getMilliseconds() // 毫秒}
    if (/(y+)/.test(fmt)) {fmt = fmt.replace(RegExp.$1, (data.getFullYear() + '').substr(4 - RegExp.$1.length))
    }
    for (var k in o) {if (new RegExp('(' + k + ')').test(fmt)) {fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
        }
    }
    return fmt
}
// 导出的文件目录地位
const SQL_PATH = resolve('./menu.sql')
// 导出 SQL 的函数
function createSQL(data, name = '', pid ='0', arr = []) {data.forEach(function(v, d) {if (v.children && v.children.length) {createSQL(v.children, name + '-' + v.name, v.id, arr)
        }
        arr.push({
            id: v.id,
            created_at: format(new Date(), 'yyyy-MM-dd hh:mm:ss'),
            modified_at: format(new Date(), 'yyyy-MM-dd hh:mm:ss'),
            created_by: 1,
            modified_by: 1,
            version: 1,
            is_delete: 'N',
            code: (name + '-' + v.name).slice(1),
            name: v.name,
            title: v.title,
            icon: v.icon,
            uri: v.uri,
            sort: d + 1,
            parent_id: pid,
            type: v.type,
            component_path: v.componentPath,
            redirect_uri: v.redirectUri,
            full_screen: v.fullScreen === 'Y' ? 'Y' : 'N',
            hidden: v.hidden === 'Y' ? 'Y' : 'N',
            no_cache: v.noCache === 'Y' ? 'Y' : 'N'
        })
    })
    return arr
}

fs.readFile(resolve('src/mock/menu.json'), 'utf-8', (err, data) => {const menuList = createSQL(JSON.parse(data))
    const sql = menuList
        .map(sql => {
            let value = ''
            for (const v of Object.values(sql)) {
                value += ','
                value += v ? `'${v}'` : null
            }
            return 'INSERT INTO `t_sys_resource` VALUES (' + value.slice(1) + ')' + '\n'
        })
        .join(';')
    const mySQL =
        'DROP TABLE IF EXISTS `t_sys_resource`;' +
        '\n' +
        'CREATE TABLE `t_sys_resource` (' +
        '\n' +
        '`id` varchar(64) NOT NULL,' +
        '\n' +
        "`created_at` timestamp NULL DEFAULT NULL COMMENT' 创立工夫 '," +
        '\n' +
        "`modified_at` timestamp NULL DEFAULT NULL COMMENT' 更新工夫 '," +
        '\n' +
        "`created_by` varchar(64) DEFAULT NULL COMMENT' 创建人 '," +
        '\n' +
        "`modified_by` varchar(64) DEFAULT NULL COMMENT' 更新人 '," +
        '\n' +
        "`version` int(11) DEFAULT NULL COMMENT' 版本(乐观锁)'," +
        '\n' +
        "`is_delete` char(1) DEFAULT NULL COMMENT' 逻辑删除 '," +
        '\n' +
        "`code` varchar(150) NOT NULL COMMENT' 编码 '," +
        '\n' +
        "`name` varchar(50) DEFAULT NULL COMMENT' 名称 '," +
        '\n' +
        "`title` varchar(50) DEFAULT NULL COMMENT' 题目 '," +
        '\n' +
        "`icon` varchar(50) DEFAULT NULL COMMENT' 图标 '," +
        '\n' +
        "`uri` varchar(250) DEFAULT NULL COMMENT' 门路 '," +
        '\n' +
        "`sort` int(11) DEFAULT NULL COMMENT' 排序 '," +
        '\n' +
        "`parent_id` varchar(64) DEFAULT NULL COMMENT' 父 id'," +
        '\n' +
        "`type` char(2) DEFAULT NULL COMMENT' 类型 '," +
        '\n' +
        "`component_path` varchar(250) DEFAULT NULL COMMENT' 组件门路 '," +
        '\n' +
        "`redirect_uri` varchar(250) DEFAULT NULL COMMENT' 重定向门路 '," +
        '\n' +
        "`full_screen` char(1) DEFAULT NULL COMMENT' 全屏 '," +
        '\n' +
        "`hidden` char(1) DEFAULT NULL COMMENT' 暗藏 '," +
        '\n' +
        "`no_cache` char(1) DEFAULT NULL COMMENT' 缓存 '," +
        '\n' +
        'PRIMARY KEY (`id`),' +
        '\n' +
        'UNIQUE KEY `code` (`code`) USING BTREE' +
        '\n' +
        ") ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=' 资源 ';" +
        '\n' +
        sql
    fs.writeFile(SQL_PATH, mySQL, err => {if (err) return console.log(err)
        console.log(chalk.cyanBright(` 祝贺你,创立 sql 语句胜利,地位:${SQL_PATH}`))
    })
})
// package.json
"scripts": {
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "dev": "vue-cli-service serve",
    "menu": "node createMenu"
  },

须要生成 SQL 的时候执行一下 npm run menu 就好啦

为了不便,以上 SQL 是会先删除资源表再从新创立,导入数据库之前记得备份一下。

整个流程是不是 so easy?so easy?so easy?

后盾表建好了之后前端本人玩,自力更生的感觉香不香?

自己前端资深小白,不足之处还望各位指教~

退出移动版