Role-Based Access Control
,權限與角色相關聯,用戶經過成爲適當角色的成員而獲得這些角色的權限。這就極大地簡化了權限的管理。vue有不少優秀的後臺管理系統模板,這些開源項目都提供了RBAC權限控制的思路,可是在實際項目中,寫死角色的方式可能並不適合。javascript
看了網上蠻多的解決方案,我的感受都有弊端,好多都是前端先把完整的路由表註冊到項目中,而後經過後臺返回樹過濾顯示的方案,這樣的作法其實只是隱藏了左側菜單,可是路由仍是已經註冊進去了,用戶猜到訪問路徑仍是能夠輕易進入頁面,沒有真正的作到動態路由加載
css
如下是我設計的解決方案,本人小白,僅供參考 -。-前端
先看一下已經完成的系統結構,便於理解,用戶綁定角色(一對多),角色綁定菜單(一對多)vue
用戶菜單
選擇角色
角色菜單
選擇菜單,因爲本項目是多系統,因此會有 ADMIN 和 HMI 兩個子系統,後面再來解釋
資源管理(我這裏沒有叫作菜單管理,由於會涉及到各個子系統我稱做模塊,模塊下面有菜單,菜單下面有按鈕)
好了,看完幾張圖你們估計也明白了這就是典型的RBAC。內部具體怎麼運做的呢java
要實現動態添加路由,即只有有權限的路由纔會註冊到Vue實例中,核心是vue-router的addRoutes和導航鉤子beforeEach兩個方法
大致思路爲,在beforeEach方法中(即每次路由跳轉以前作判斷),若是已經加載了路由表,則把路由表註冊到實例中,若是沒有,則從後端拉取路由表註冊到實例中。那爲何不在登陸的時候加載一次就能夠了呢?這是由於若是隻是登陸的時候加載一次,網頁刷新的時候註冊的路由表會丟失,因此咱們統一在beforeEach這個鉤子中去實現node
// 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() })
因爲路由是動態註冊的,因此項目的初始路由就會很簡潔,只要提供基礎的路由,其餘路由都是從服務器返回以後動態註冊進來的webpack
// 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以前不支持動態編譯,因此不少項目都在路由表維護了一份映射表以下:git
const routerMap = { user: () => import('/views/user'), role: () => import('/views/role'), ... }
我以爲這樣很不nice,最新版的vue-cli集成的webpack已經能夠支持動態導入啦,所以能夠把全部的路由信息所有放到數據庫裏面配置,前端不在須要維護一份router的映射關係表啦,若是你是老版的CLI,可使用dynamic-import
web
咱們再來看看下面這個神奇的文件,也能夠大體瀏覽一下而後看下面的解釋
// 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" } ] } ]
以上是前端的路由配置信息,以前提到過,路由是後端返回的,爲何前端還有一份菜單文件呢?vue-router
由於路由裏面的內容所有都是前端須要使用的,好比菜單顯示的圖表,菜單對應的前端路徑等等...既然和前端關係比較大,因此前端維護該文件更適合,而不是讓後端去配置XML或者liquibase。你每次菜單有修改的時候要通知你的後臺要更新一下數據庫,而後切換多個環境的時候每一個後臺都要通知一聲...後臺還不必定樂意X你...而後你想改個小圖標都要當心翼翼被你的後臺大佬懟...
固然若是是多環境部署的話仍是讓後臺使用liquibase比較合適,可是開發模式下前端徹底能夠本身玩。等到須要多環境(不一樣數據庫)同時部署的時候,你把sql語句給後端就OK了,文章下面會提到怎麼導出sql...
Question:
既然前端有該文件,是否是意味着路由的源碼又暴露出去了,那和別人的猜路徑就能夠訪問有什麼區別?不是說好了從數據庫拉取菜單信息,你跟我直接用這個json,那數據庫咋整?別急...
Answer:
配置
文件,build的時候不會打包該內容。接下來就是如何註冊這些路由表了
// 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 execSync = require('child_process').execSync //同步子進程 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') // 獲取全局配置的git的用戶名,用來追蹤SQL是誰導出的(出問題鍋是誰的QAQ) const myname = execSync('git show -s --format=%cn') .toString() .trim() // 導出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: myname, modified_by: myname, 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?
後臺表建好了以後前端本身玩,自給自足的感受香不香?
本人前端資深小白,不足之處還望各位指教~
歡迎加入羣聊