萬字長文~vue+express+mysql帶你徹底搞懂專案中的許可權控制(附所有原始碼)

水冗水孚發表於2023-01-16
本文略長,建議收藏。

線上網站演示效果地址:http://ashuai.work:8891/home

GitHub倉庫地址:https://github.com/shuirongsh...

所謂的許可權,其實指的就是:使用者是否能看到,以及是否允許其對資料進行增刪改查的操作,因為現在開發專案的主流方式是前後端分離,所以整個專案的許可權是後端許可權控制搭配前端許可權控制共同實現的

後端許可權

1. 知道是哪個使用者(角色)發的請求

  • token判斷(前端請求頭中傳過去,透過請求攔截器)
  • userid判斷(前端請求頭中傳過去,透過請求攔截器)
  • cookie判斷(用的少)
  • session判斷(用的很少)

2. 許可權設計模式

RBAC模式~基於角色的許可權訪問控制(Role-Based Access Control)是商業系統中最常見的許可權管理技術之一。

關於什麼是RBAC模式的概念本文這裡不做贅述,大家可以這樣理解:

2.1 RBAC模式

  • 某個系統有一些人在使用,但是使用這個系統的這些人分為兩個派系。有老闆派(老闆、老闆小姨子、老闆小舅子等),也有打工仔派(張三、李四、王二、麻子)。
  • 系統有一些頁面用於展示資料,作為老闆派的相關工作人員,肯定是哪個頁面都能夠看到,畢竟公司都是老闆的,必須什麼都能看到
  • 而作為打工仔派的工作人員,肯定是許可權比較低了,只能看到一個業績頁面(假設業績頁面記錄了自己每個月為公司創造的業績)
  • 這個案例中的老闆派和打工仔派就是兩個角色,而老闆、老闆小姨子、老闆小舅子和張三、李四、王二、麻子這些使用者就是隸屬於角色的使用者
  • 使用者是角色的具象化體現
  • 使用者的數量一般都大於角色的數量的(想一下...)
  • 所以我們在做許可權控制的時候,只需要控制角色能看到那個頁面即可,至於使用者就讓他隸屬於這個角色便是
  • 透過使用者和角色進行關聯,也做到了複用和解耦
  • 角色能看到哪個頁面,取決於角色和選單頁面進行的關聯(透過勾選選單樹關聯)

步驟如下:

  • 給對應角色賦予(分配)相應選單許可權
  • 讓新建的使用者隸屬於某個角色,就是給新建的使用者分配一下角色

2.2 後端建表

正常情況下需要五張表:

  • 選單表(用於儲存系統的選單資訊,如選單的名字、點選這個選單要去跳轉的路由url、以及這個選單的icon圖示名、選單id等...)
  • 角色表(用於儲存系統的角色資訊,如角色名、角色id、角色備註等...)
  • 角色選單表(用於儲存某個角色能看到選單id有哪些,一個角色id對應多個選單id,一對多關係)
  • 使用者表(用於儲存使用者隸屬於哪個角色,比如老闆小舅子就是隸屬於老闆角色,以及使用者名稱、使用者id、使用者備註啥的...)
  • 組織表(用於記錄角色屬於哪個組織,再大一些的系統專案會建此表,小專案沒有也行)

三張表也能用

我們將角色選單表,糅在角色表中,這樣的話,只要新建選單表、角色表(包含角色選單資訊)、使用者表,根據使用者的使用者名稱和密碼進行登入(後端會根據使用者名稱和密碼查詢到這個使用者隸屬於那個角色下,從而返回此使用者對應角色的選單資訊資料)

本文演示建兩張表

為了更好的便於大家理解,本文只新建兩張表,一張是選單表,另一張是角色表(角色表中存此角色能看到的單id),而登入的時候,大家選擇角色登入,其實選擇使用者登入從某種意義上來說,也是相當於角色登入。

第一步:

角色登入發請求,後端返回此角色對應的選單樹資料(需要前端進一步加工一下),前端獲取選單樹資料以後,將其存到vuex中比如是menuTree陣列欄位,el-menu元件再取到vuex中的menuTree陣列資料使用,根據這個選單樹資料進行自動遞迴渲染

關於el-menu元件的遞迴自呼叫,可以參見筆者之前的這篇文章:https://segmentfault.com/a/11... 若對於元件遞迴知識忘了,可以參見筆者的這篇文章:https://segmentfault.com/a/11...

第二步:

第一步中,完成了el-menu元件的渲染展示,但是還少了路由表樹結構陣列資料(因為點選選單需要進行路由跳轉),所以我們還需要再搞一份資料routerTree,這個routerTree資料是根據後端返回的選單樹資料,加工成符合路由表樹結構的陣列,這個陣列是給路由表使用的

  • routerTree的值是動態,因為不同角色的routerTree不一樣
  • 對應的路由表還有靜態的,不變的,比如404路由、login路由、首頁home路由
  • 靜態路由前端直接寫死即可,動態路由使用router.addRoutes(routerTree)將其新增到路由表中即可

至於重新整理頁面vuex中資料丟失,就重發一次請求,或者本地存一份都行的

實際上,重新整理頁面,並不是vuex中的資料丟失,而是,而是vuex中的資料初始化了(回到最初的樣子,最初時,vuex中的資料本來就是空的)

透過上述兩步驟,一個許可權系統的基本樣子結構就出來了,只不過還有一些細節需要處理,這個請繼續往下閱讀

前端許可權細化控制

前端許可權細化分類大致可以分為四種:

  • 選單的許可權控制
  • 頁面的許可權控制
  • 按鈕的許可權控制
  • 欄位的許可權控制

1.選單的許可權控制(以左側導航選單為例)

不同角色的使用者登入以後,看到的是不同的選單內容。比如:

  • 普通角色(的使用者),只能看到某一個選單
  • 管理員角色能看到所有的選單
  • el-menu選單元件進行舉例說明

2.頁面的許可權控制

  • 角色沒登入時,手動在位址列輸入url地址跳轉,就強制使用者跳轉到登入頁
  • 角色登入後,手動在位址列輸入不存在的url(或者自己不能看的url)地址跳轉,讓其跳轉404頁面
  • 某些特殊的頁面,還可以使用vue-router中的beforeRouteEnter再進行細化控制

假設打工仔張三,想要去看老闆的頁面,因為自己登入時,後端返回的選單樹中沒有老闆頁面的資料,所以路由表中也沒有老闆頁面的路由,所以位址列url直接跳轉時,就會跳轉到一個不存在的路由頁面,就不會顯示出東西了。

關於路由,大家也可以這樣理解:就是當位址列中輸入對應的pathurl時,路由表大佬去做對應匹配,匹配到了以後,再去渲染對應的.vue元件從而呈現對應的資料內容(匹配不到那就404唄)

3.按鈕的許可權控制

按鈕的控制,可以細分為兩塊:一是 : 是否能看到這個按鈕、另外是 : 能看到不過是否能點選這個按鈕(按鈕是否禁用)

  • 按鈕是否展示,取決於是否給角色分配了按鈕許可權。
  • 如有的只能看到檢視按鈕,有的增刪改查按鈕都可以看到
  • 再一個,我們可以把按鈕當做是一個特殊的選單頁面
  • 最後,要有一個規則限制,新增節點時,可以新增選單節點,也可以新增按鈕節點,只不過按鈕節點永遠是最底層的位置,不能在按鈕節點下再去新增頁面(無意義操作)
  • 不理解上面那句話,請繼續往下閱讀

注意,在專案中最好不要使用禁用按鈕去控制許可權,如果角色使用者沒有某個按鈕的許可權,直接刪除這個節點即可,比如v-if,或el.parentNode.removeChild(el),因為使用禁用按鈕去控制許可權存在一定風險,如筆者的這篇文章:https://segmentfault.com/a/11...

特殊情況下,使用禁用按鈕也可以去分配控制許可權,具體情況具體分析

4. 欄位的許可權控制

如下的表格:

姓名年齡家鄉愛好
孫悟空500花果山大鬧天宮

比如年齡欄位是隱私,只有老闆能看到員工的年齡,老闆的小姨子和小舅子都不能看到,這個需求的實現可以前端根據角色id進行過濾;或者後端再建表做對映關係,返回給前端。

篇幅原因,這裡不贅述了。後續空閒了,筆者再寫一篇介紹

後端建選單表和角色表

我們先從後端開始寫,關於每個欄位的意思,筆者在程式碼中提到了

1. 選單表

兩張表都在程式碼中,大家閱讀完文章以後,可以在GitHub倉庫中自行獲取

1.1選單表資料庫截圖

這裡有幾個欄位需要著重介紹一下:

1.2pid欄位

pid欄位,即為:parentId是父級節點欄位,就是當前節點的父節點,後端在資料庫中儲存前端選單樹資料時,是不會存成樹結構的資料的(JSON形式除外,但這種方式很少用)後端儲存的資料是:把樹結構的資料鋪平(拍平)以後的資料。

比如我們有這樣一個樹結構資料

let treeData = [
        {
                id: 1,
                name: '中國',
                children: [ // 有的後端喜歡使用child欄位,一個意思
                        {
                                id: 3,
                                name: '北京',
                        },
                        {
                                id: 4,
                                name: '上海',
                                children: [
                                        {
                                                id: 6,
                                                name: '浦東新區'
                                        }
                                ]
                        },
                ]
        },
        {
                id: 2,
                name: '美國',
                children: [ // 有的後端喜歡使用child欄位,一個意思
                        {
                                id: 5,
                                name: '紐約',
                        },
                ]
        },
]

資料庫中可不會直接存一個樹結構,資料庫會把樹結構拍平存起來,即這樣儲存:

pididname
01中國
13北京
14上海
46浦東新區
02美國
25紐約

注意!資料庫中不需要儲存children欄位,children欄位是一個虛擬欄位,是當後端同事查詢選單表資料結構時,將扁平化的資料轉成樹結構時,遞迴程式碼建立的,並返回給前端。

實際上,後端同事可以直接將sql語句查詢到的扁平化資料庫資料陣列直接丟給前端,至於加工成樹結構,也可以由前端同事去加工,不過正常情況下,扁平化資料轉成樹結構資料都是後端做的,不過前端也要會寫相應遞迴函式

也就是相當於這樣的JSON

[
        {
                "pid": 0,
                "id": 1,
                "name": "中國",
        },
        {
                "pid": 1,
                "id": 3,
                "name": "北京",
        },
        {
                "pid": 1,
                "id": 4,
                "name": "上海",
        },
        {
                "pid": 4,
                "id": 6,
                "name": "浦東新區",
        },
        {
                "pid": 0,
                "id": 2,
                "name": "美國",
        },
        {
                "pid": 2,
                "id": 5,
                "name": "紐約",
        }
]

至於樹結構如何拍平的,可以筆者寫了兩個遞迴函式大家可以看看,函式寫法二的可讀性更好一些哦

函式寫法一:

// 拍平加pid欄位                 
function pidFn(data, sqlArr = [], pid = 0) { // 假設頂級的pid為0
        data.forEach((item) => { // 遍歷得到每一項
                let obj = JSON.parse(JSON.stringify(item)) // 深複製一份
                obj.pid = pid // 給每一項都賦值pid
                delete obj.children // 拍平不需要children
                sqlArr.push(obj) // 丟到sqlArr陣列中
                if (item.children) { // 有子節點就遞迴操作
                        pidFn(item.children, sqlArr, item.id) // 當前項的id就是子項的pid
                }
        })
        return sqlArr // 一波操作,最後再丟出來即可
}
let res = pidFn(treeData)
console.log('拍平加父id欄位', res);

函式寫法二:

function pidFn(data) {
    let sqlArr = [] // 定義一個陣列用於儲存拍平後的資料
    function digui(data, pid) { // 專門定義個遞迴函式使用者清晰的儲存資料
            data.forEach((item) => { // 遍歷樹結構資料
                    let obj = JSON.parse(JSON.stringify(item)) // 深複製一份
                    obj.pid = pid // 給每一項都賦值pid
                    delete obj.children // 拍平了,就不需要children了
                    sqlArr.push(obj) // 丟到sqlArr陣列中
                    if (item.children) { // 如果樹結構有子節點就繼續遞迴操作
                            // 當前節點的id就是子節點的pid
                            digui(item.children, item.id) // 遞迴函式接收樹結構資料,以後pid引數
                    }
            })
    }
    digui(data, 0) // 遞迴函式初次執行,假設頂級pid是0
    return sqlArr // 將遞迴的結果丟出去
}

let res = pidFn(treeData)
console.log('拍平加父id欄位', res);

1.3pids欄位

pids欄位是所有的父級節點的組合的陣列,比如一個三級節點,它的pid是二級節點的id,而它的pids是所有的父級節點,包括二級節點id和一級節點id

當然資料庫儲存,不能直接存一個陣列進去,所以toString()轉成字串儲存即可

1.4cUrl欄位

cUrlcomponentUrl元件的地址的意思。就是路由表中用於讀取元件的component函式。比如有以下一個路由表:

{
    path: "/welcome",
    name: "welcome",
    component: resolve => require(["@/views/pages/welcome.vue"], resolve),
},

在資料庫中儲存為:

urlcUrl...
/welcome/pages/welcome.vue...

表示:當位址列的url值為/welcome時,去讀取並渲染@/views/pages/welcome.vue檔案,即做到了url頁面的對應關係

其他的欄位含義,看上述圖片的註釋即可理解

1.5選單表sql程式碼

DROP TABLE IF EXISTS `menus`;
CREATE TABLE `menus`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '唯一id',
  `pid` int(11) NOT NULL COMMENT '上級父節點的id,即為parentId(注意,children欄位是不用儲存,children欄位是遞迴時,新增進去的)',
  `pids` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '上級節點的id陣列轉的字串',
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '樹節點的名字',
  `url` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '即為選單的path',
  `cUrl` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '當訪問url時,前端路由需要讀取並渲染的.vue檔案的路徑,一般是相對於views裡的',
  `type` int(255) NULL DEFAULT NULL COMMENT 'type為1是選單,為2是按鈕',
  `icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '選單的圖示名',
  `sort` int(255) NULL DEFAULT NULL COMMENT '選單的上下排序',
  `status` int(255) NULL DEFAULT NULL COMMENT '是否開啟欄位,1是開啟,2是關閉',
  `isHidden` int(255) NULL DEFAULT NULL COMMENT '是否隱藏選單,1是顯示,2是隱藏',
  `isCache` int(255) NULL DEFAULT NULL COMMENT '是否快取,1是快取,2是不快取',
  `remark` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '備註',
  `isDel` int(255) NULL DEFAULT 1 COMMENT '刪除標識,1代表未刪除可用,2代表已刪除不可用',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 105 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Compact;

2. 角色表

2.1角色表資料庫截圖

2.2menuIds欄位

實際專案中,會有一個角色選單表用於做角色和選單的對映關係,這裡筆者直接糅在一塊了,便於大家理解。

2.3角色表sql程式碼

DROP TABLE IF EXISTS `roles`;
CREATE TABLE `roles`  (
  `roleId` int(255) NOT NULL AUTO_INCREMENT COMMENT '每一個角色的id',
  `roleName` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '每一個角色的name名字',
  `roleRemark` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL DEFAULT NULL COMMENT '角色的備註',
  `menuIds` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NULL COMMENT '當前的這個角色能看到(勾選)的選單的id(給角色賦予選單)',
  PRIMARY KEY (`roleId`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 32 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = Compact;

前端使用角色去登入控制頁面的許可權

1. 登入頁(選擇角色發請求獲取對應選單樹資料)

1.1效果圖

1.2程式碼思路

el-select點選下拉框

<el-select
v-model="value"
placeholder="請選擇角色登入"
@visible-change="
  (flag) => {
    if (flag) {
      listRoles();
    }
  }
"
>
    <el-option
      v-for="item in roleList"
      :key="item.value"
      :label="item.roleName + ' (' + item.roleRemark + ')'"
      :value="item.menuIds"
    >
    </el-option>
</el-select>

發請求獲取角色列表有哪些

async listRoles() {
  const res = await this.$auth.listRoles();
  if (res.code == 0) {
    this.roleList = res.data;
  }
}

介面返回角色列表有以下資料

[
    {
        "roleId": 29,
        "roleName": "超級管理員",
        "roleRemark": "能看到所有",
        "menuIds": "1,19,21,42,30,31,41,22,32,33,27,34,39,40,35,36,37,38,100,101,99,25,26"
    },
    {
        "roleId": 30,
        "roleName": "前端",
        "roleRemark": "只能看前端相關",
        "menuIds": "1,19,21,42,30,31,41,22,32,33,27,34,39,40,35"
    },
    {
        "roleId": 31,
        "roleName": "後端",
        "roleRemark": "只能看後端相關",
        "menuIds": "1,36,37,38,100,101"
    }
]

當我們選擇以超級管理員登入時,就根據超級管理員roleId為29對應的能看到的menuIds有哪些的選單,去查詢對應的選單資料。

/auth/roleMenuByMenuId?menuIds=1,19,21,42,30,31,41,22,32,33,27,34,39,40,35,36,37,38,100,101,99,25,26

後端介面如下:

// 根據角色id查詢能看到的選單有哪些(前提是這些選單是啟用狀態的)
route.get('/roleMenuByMenuId', (req, res) => {
  // 1. 接收前端傳的引數
  let menuIds = req.query.menuIds 
  // 2. 拼接sql語句準備去資料庫查詢
  let sql = `SELECT * FROM menus WHERE id IN (${menuIds}) AND isDel = 1 AND status = 1`
  // 資料庫連線池建立連線去資料庫中撈資料
  pool.getConnection(function (err, connection) {
    if (err) { throw err }
    connection.query(sql, function (error, results, fields) {
      connection.release()
      let apiRes = {
        code: 0,
        msg: "成功",
        // 注意!注意!注意!
        // 這時候撈到的資料還是扁平化的陣列
        // 需要將扁平化的陣列轉成樹結構
        // 所以這裡定義了一個changeTree函式用於加工資料
        data: changeTree(results, 0, 0) 
      }
      res.send(apiRes)
    })
  })
})
如果大家對於express寫介面,做資料庫查詢忘了,可以看下筆者之前寫的一篇全棧文章複習一下遺忘的知識:https://segmentfault.com/a/11...

列印查到的results結果:扁平化陣列資料截圖:

所以需要有個函式方法(工具類),能夠將扁平化結構轉成樹結構,函式如下:

/**
 * 想要將扁平化陣列轉成樹結構,首先必須知道頂級的pid是啥(0)
 * 第一步,假設我們只需要找頂級的這一項,
 * 只需要對比一下那一項的pid是這個pid即可
 * 而後遞迴即可
 * */
// 透過pid將扁平化的陣列轉成樹結構。給樹結構新增level欄位(資料庫中沒存,當然存也可以)
module.exports = function changeTree(arr, pid, level) {
    let treeArr = []
    level = level + 1 // 新增層級欄位
    arr.forEach((item, index) => {
        if (item.pid == pid) {
            // 把不是這一項的剩餘幾項,都丟到這個children陣列裡面,再進行遞迴(這一項已經確定了,是父級,沒必要再遞迴了)
            let restArr = arr.filter((item, index) => { return item.pid != pid })
            item['children'] = changeTree(restArr, item.id, level) // 這裡需要進行傳id當做pid(因為自己的id就是子節點的pid)
            if (item.children.length == 0) { delete item.children } // 加一個判斷,若是children: [] 即沒有內容就刪除,不返回給前端children欄位
            item['level'] = level // 新增層級欄位
            // 操作完以後,把一整個的都追加到陣列中去
            treeArr.push(item)
        }
    });
    return treeArr
}

呼叫:changeTree(results, 0, 0) 這裡要告知頂級的pid,這裡我假設為0,同時level層級從第0層開始

經過這樣一加工再返回給前端,就是一個樹結構的資料了,如下圖:

這樣的話,前端就可以使用了

因為業務簡單,所以這裡筆者的後端express沒有分層處理

關於後端的分層,前端同事可以這樣簡單理解:

控制層
    接收引數
    校驗引數
    處理引數
    .....
業務層 
    資料處理查、改什麼的...
持久層
    生成sql語句
    資料庫操作
    資料返回

2.加工後端返回的選單樹資料存在vuex中以供使用

2.1登入成功跳轉

async loginIn() {
  // vuex中發相應的請求
  const res = await this.$store.dispatch("menu/tree_menu", this.value);
  if (res.code == 0) {
    // 存一份登入的角色名
    let i = this.roleList.findIndex((item) => {
      return item.menuIds == this.value;
    });
    sessionStorage.setItem("username", this.roleList[i].roleName);
    // 然後登入成功去跳轉
    this.$message({
      type: "success",
      message: "登入成功",
    });
    this.$router.push({ path: "/" });
  }
},

2.2vuex中的操作~el-menu需要的選單樹

  • 注意,上述圖中,後端是返回了選單樹,但是!但是!依舊是不能直接使用!
  • 原因是前端還需要再進行一次加工,將選單樹加工成兩個樹結構的資料
  • 一個是menuTree: [], // el-menu需要的選單樹
  • 另一個是routerTree: [] // vue-router需要的路由樹
  • el-menu選單樹需要的menuTree的值是不包含按鈕節點的,而因為按鈕被當做了特殊的頁面節點,所以這裡需要過濾一下
  • vue-router需要的路由樹routerTree也要根據後端返回的選單樹進行加工,加工成具有component函式屬性值的路由樹資料

我們先看el-menu選單樹需要的menuTree資料的加工函式

// 加工後端返的樹結構資料 是給選單遞迴元件el-menu使用的
export function setElMenuTreeWithoutBtn(oldTree, newTree) {
    oldTree.forEach((item) => {
        let newTreeObj = {
            ...item,
            children: null
        }
        if (item.children) { // 有子集內容,且子集內容為選單type=1才去遞迴(按鈕type=2就不要了,這樣就過濾了...)
            if (item.children[0].type == 1) {
                setElMenuTreeWithoutBtn(item.children, newTreeObj.children = [])
            }
        }
        if (newTreeObj.children == null) { // 為null說明沒有子集,或者子集都是按鈕被忽略了,刪除之
            delete newTreeObj.children
        }
        newTree.push(newTreeObj)
    })
    return newTree
}

呼叫此方法是在vuex中的登入邏輯中

const state = {
    isCollapse: false,
    menuTree: [], // el-menu需要的選單樹
    routerTree: [] // vue-router需要的路由樹
};

const mutations = { ... }

const actions = {
    // 相當於login登入介面
    tree_menu({ commit, dispatch }, menuIds) {
        return new Promise((resolve, reject) => {
            roleMenuByMenuId(menuIds)
            .then(
                (res) => {
                    let menuTree = res.data[0].children // 頂級節點PC不要
                    commit('TREE_MENU', setElMenuTreeWithoutBtn(menuTree, [])) // 加工選單樹給到el-menu元件使用
                    commit('ROUTE_TREE', setRouterTree(menuTree, [])) // 加工選單樹給到路由表使用
                    sessionStorage.setItem("token", "token"); // 模擬token儲存
                    sessionStorage.setItem("menuIds", menuIds); // 存一份當前角色的menuIds
                    setBtnAuth(menuTree) // 設定按鈕
                    resolve(res) // 丟擲去告知結果
                }
            ).catch(...)
        })
    }
}

最終加工好的menuTree樹結構資料,會被el-menu元件使用:

注意,el-menu元件放在檢視層元件中:

讓我們來看一下layout資料夾中的index.vue元件是如何使用menuTree資料的吧

layout-->index.vue

<el-menu
    ref="elMenu"
    :collapse="isCollapse"
    :default-active="activeIndex"
    class="elMenu"
    background-color="#333"
    text-color="#B0B0B2"
    active-text-color="#fff"
    :unique-opened="false"
    router
    @select="menuSelect"
  >
    <!-- 普通選單(前端寫死固定,如首頁、歡迎頁等) -->
    <el-menu-item index="/">
      <i class="el-icon-s-home"></i>
      <span slot="title">首頁</span>
    </el-menu-item>
    <!-- 遞迴動態選單(後端返回,不同角色選單不一致) -->
    <myitem :data="menuArr"></myitem>
  </el-menu>
  
  import myitem from "./components/myitem.vue";
  components: { myitem }
  data(){
      return {
          menuArr: this.$store.state.menu.menuTree
      }
  }

再複習一下遞迴元件

layout-->components-->myitem.vue

<template>
  <div>
    <template v-for="(item, index) in data">
      <!-- isHidden值等於1才去顯示,等於2隱藏 -->
      <template v-if="item.isHidden == 1">
        <!-- 因為有子集和無子集渲染html標籤不一樣,所以要分為兩種情況:情況一:有子集的情況:-->
        <template v-if="item.children">
          <!-- 有子集去遞迴顯示 -->
          <el-submenu :key="index" :index="item.url">
            <template slot="title">
              <i class="el-icon-platform-eleme"></i>
              <span>{{ item.name }}</span>
            </template>
            <myitem :data="item.children"></myitem>
          </el-submenu>
        </template>
        <!-- 情況二:沒子集的情況 -->
        <template v-else>
          <!-- 沒子集直接顯示內容即可 -->
          <el-menu-item :key="index" :index="item.url">
            <i class="el-icon-eleme"></i>
            <span slot="title">{{ item.name }}</span>
          </el-menu-item>
        </template>
      </template>
    </template>
  </div>
</template>

<script>
export default {
  name: "myitem",
  props: {
    data: {
      type: Array,
      default: [],
    },
  },
  // 注意: 在template標籤上使用v-for,:key="index"不能寫在template標籤上,因為其標籤不會被渲染,會引起迴圈錯誤
};
</script>
關於元件的name屬性,其實不寫也行,不過當需要使用到元件的遞迴自呼叫或使用keep-alive快取元件時,就得加上了。當然也有別的方案...

到目前為止,選單能夠顯示了,但是點選選單卻是空白頁,因為少了路由表樹結構資料了

2.3vuex中的操作~vue-router需要的路由樹

路由表中的路由分為靜態路由(固定路由)和動態路由(後端根據不同的使用者/角色返回的路由),比如可以有以下的靜態固定路由:

// 固定的靜態路由,比如登入頁、首頁、404頁等...
const staticRoutes = [
  {
    path: '/',
    // component: resolve => require(["@/layout/index.vue"], resolve),
    component: Layout, // 二者一個意思
    redirect: '/home',
    children: [
      {
        path: "/home",
        name: "home",
        component: resolve => require(["@/views/home.vue"], resolve),
      },
    ]
  },
  {
    path: '/login',
    component: resolve => require(["@/views/login.vue"], resolve),
  },
  {
    path: '/404',
    component: resolve => require(["@/views/404.vue"], resolve),
  },
  // { path: '*', redirect: '/404' } 
]
這裡有一個坑,404頁面的匹配兜底路由在最後,但是不能直接寫在靜態路由中的最後一個,會重新整理自動到404頁面了。因為是動態路由的做法,所以404頁面的匹配兜底路由拼接在vue-router的路由樹陣列資料中即可。所以上述的{ path: '*', redirect: '/404' } 筆者註釋掉了。

登入獲取到後端返回的樹結構資料後,加工,給路由表使用

// vuex中
roleMenuByMenuId(menuIds).then(
    (res) => {
        let menuTree = res.data[0].children // 頂級節點PC不要
        commit('ROUTE_TREE', setRouterTree(menuTree, []))
    }
)

setRouterTree加工函式

// 加工後端返的樹結構資料 是給vue-router路由表使用
import Layout from '@/layout/index.vue'
let page404 = { path: '*', redirect: '/404' }
export function setRouterTree(oldTree, newTree) {
    oldTree.forEach((item) => {
        let newTreeObj = {
            path: item.level == 2 ? `/${item.url}` : `${item.url}`,
            name: item.name,
            component(resolve) { require([`@/views${item.cUrl}`], resolve) },
            meta: {
                title: item.name
            }
        }
        if (item.level == 2) { // 如果是二級,就統一使用layout元件檢視層
            // newTreeObj['component'] = Layout // 這兩個一個意思
            newTreeObj['component'] = (resolve) => {
                require(["@/layout/index.vue"], resolve)
            }
        }
        if (item.children) {
            if (item.children[0].type == 1) { // 路由表樹結構也不需要按鈕哦,只要type等於1的選單
                setRouterTree(item.children, newTreeObj.children = [])
            }
        }
        newTree.push(newTreeObj)
    });
    return newTree.concat(page404) // 將404頁面拼接到最後面,做通配路由使用
}

列印加工好的路由樹結果:

注意箭頭指向的地方,component函式要去引用指向解析元件哦,這個一定要有

所以commit('ROUTE_TREE', setRouterTree(menuTree, []))的值給到路由表去使用

那麼?路由表如何使用加工好的這個路由表樹資料呢?使用vueaddRoutes方法新增即可,在登入成功以後。加工好以後,直接新增即可

import router from "@/router";
import store from "@/store";

async loginIn() {
      const res = await this.$store.dispatch("menu/tree_menu", this.value);
      if (res.code == 0) {
        /**
         * 登入成功以後也要動態新增一下路由tip1,或者非同步重載入一下tip2
         * 否則會出現首次登入router.beforeEach中動態路由方法不觸發
         * 即首次登入點選動態選單部分出現空白頁(重新整理後正常)
         * */
        router.addRoutes(store.state.menu.routerTree); // tip1: 注掉效果明顯
        let i = this.roleList.findIndex((item) => {
          return item.menuIds == this.value;
        });
        sessionStorage.setItem("username", this.roleList[i].roleName);
        this.$message({
          type: "success",
          message: "登入成功",
        });
        this.$router.push({ path: "/" });
        // setTimeout(() => { // tip2:注掉效果明顯
        //   location.reload();
        // }, 10);
      }
    },

最後一步,重新整理頁面時,vuex初始化,所以在beforeEach鉤子函式中重新發請求,獲取選單樹資料即可

// 路由全域性攔截
router.beforeEach((to, from, next) => {
  // 去登入頁面肯定放行的,管他有沒有token
  if (to.path === '/login') {
    next()
  }
  // 去的不是登入頁面,再看看有沒有token認證
  else {
    const token = sessionStorage.getItem('token')
    if (!token) { // 沒token,就讓其回到登入頁登入
      next({ path: "/login" })
    } else { // 有token,再看看有沒有選單路由資訊
      if (store.state.menu.routerTree.length > 0) { // 有選單資訊,就放行
        next()
      } else { // 沒有選單資訊,就再發一次請求獲取選單資訊
        let menuIds = sessionStorage.getItem('menuIds')
        store.dispatch('menu/tree_menu', menuIds).then((res) => {
          if (res.code == 0) {
            router.addRoutes(store.state.menu.routerTree)
            next({ ...to, replace: true }) // 確保動態路由已被完全新增進去了
          }
        })
      }
    }
  }
})

前端使用角色去登入控制按鈕的許可權

按鈕的許可權思路,在登入時,根據選單樹轉成一個按鈕樹,有按鈕的許可權,就為true,沒有就直接沒有。

然後再定義函式去獲取某某頁面下的某某按鈕的值是否為true,為true就說明有許可權,為false就說明沒有許可權,可以使用函式引入方式,或者自定義指令方式。

將選單樹的按鈕轉成key/value布林值按鈕樹形式

目標按鈕樹結構

{
    "前端框架": {
        "vue頁面": {
            "新增vue": true,
            "編輯vue": true,
            "刪除vue": true,
            "佔位按鈕": true
        },
        "react頁面": {
            "新增react": true,
            "編輯react": true
        },
        "angular": {
            "agl1": {
                "agl1新增/編輯": true,
                "agl1刪除": true
            },
            "agl2": {}
        }
    },
    "後端框架": {
        "springBoot": {},
        "myBatis": {},
        "特工001": {},
        "特工002": {}
    },
    "系統設定": {
        "角色管理": {},
        "選單管理": {}
    }
}

定義函式去設定

依舊是在登入的時候,去根據選單樹去設定按鈕樹

roleMenuByMenuId(menuIds).then(
        (res) => {
            let menuTree = res.data[0].children // 頂級節點PC不要
            //......
            setBtnAuth(menuTree) // 設定按鈕樹
            resolve(res) // 丟擲去告知結果
        }
    )

設定按鈕樹遞迴函式

// 按鈕許可權設定
export function setBtnAuth(tree, btnAuthObj = {}) {
    // 迴圈加工
    tree.forEach((item) => {
        if (item.type == 1) { // 型別為1說明是選單
            btnAuthObj[item.name] = {} // 選單就加上一個物件屬性
            if (item.children) { // 若有子集,就遞迴加物件屬性
                // 因為物件是引用型別,所以直接賦值整個按鈕許可權物件就都有了
                setBtnAuth(item.children, btnAuthObj[item.name])
            }
        }
        if (item.type == 2) { // 型別為2說明是按鈕
            btnAuthObj[item.name] = true // 按鈕的賦值true表示有按鈕許可權
        }
    })
    // 加工完畢以後,丟出去以供使用
    sessionStorage.setItem('btnAuthObj', JSON.stringify(btnAuthObj))
    return btnAuthObj
}

獲取按鈕樹遞迴函式

// 按鈕許可權獲取
export function getBtnAuth(whichPage, btnName) { // 查詢:那個頁面下的什麼按鈕名字是否有許可權
    let flag // 找到了,才說明有許可權
    function getBtn(whichPage, btnName, btnAuthObj = JSON.parse(sessionStorage.getItem('btnAuthObj'))) {
        for (const key in btnAuthObj) {
            if (key == whichPage) {
                flag = btnAuthObj[key][btnName]
            } else {
                getBtn(whichPage, btnName, btnAuthObj[key])
            }
        }
    }
    getBtn(whichPage, btnName) // 遞迴查詢標識賦值
    return flag ? true : false // 找到了為true,沒找到undefined,這裡再判斷一下,返回布林值
}

函式使用方式

<el-button type="danger" v-if="isShowDeleteBtn">刪除vue</el-button>

computed: {
    isShowDeleteBtn() {
      return getBtnAuth("vue頁面", "刪除vue");
    },
  },

自定義指令方式

<el-button type="primary" v-btn="{ vue頁面: '新增vue' }">新增vue</el-button>
<el-button type="primary" v-btn="{ vue頁面: '編輯vue' }">編輯vue</el-button>

自定義指令v-btn

import { getBtnAuth } from "@/utils";
export default {
    inserted(el, binding, vnode) {
        const whichPage = Object.keys(binding.value)[0]
        const btnName = Object.values(binding.value)[0]
        let flag = getBtnAuth(whichPage, btnName)
        if (!flag) {
            el.parentNode.removeChild(el)
        }
    },
    unbind(el, binding, vnode) {
    }
}

相當於去查詢,查 某個頁面 中有沒有 某個按鈕

共用的動態路由元件

比如好幾個頁面,都是同一個結構內容,只是id不同,這個時候就需要使用動態路由元件了傳參

/dynaOne/:001

<template>
  <div>
    <span>共用的動態元件:</span>
    <h2>
      根據 <span class="val">{{ val }}</span> 的不同發相應請求
    </h2>
  </div>
</template>

<script>
export default {
  data() {
    return {
      val: Object.values(this.$route.params)[0].slice(1),
      /*
        注意url的配置:
        思路是多個路由path使用同一個元件,需要加上監聽,解決路由變了,元件不重新整理的問題
            身份標識id直接url中拼接即可使用
            url:'xxx/:001'
      */
    };
  },
  watch: {
    $route: {
      handler: function () {
        this.val = Object.values(this.$route.params)[0].slice(1);
      },
    },
  },
};
</script>

<style>
.val {
  color: brown;
  font-size: 36px;
  margin: 0 8px;
}
</style>

總結

  • 透過這種方式,就可以做到動態選單得了。
  • 但是紙上得來終覺淺,還是得實操
  • 又因為篇幅原因,筆者關於一些細節文章中沒有提到
  • 所以大家可以去筆者釋出出的網站上看效果
  • 以及star一下筆者的倉庫
  • 將程式碼拉下來跑起來,一點點捋清程式碼思路
  • 這樣印象才會深刻

比如後端介面如何寫,前端選單樹el-tree勾選傳參細節問題,詳情,請看程式碼

另外,後臺管理系統,都有tab標籤頁,動態路由的tab標籤頁在三層選單中會出現快取問題,後續筆者不忙了,會繼續更新這個倉庫的哦。

等等...

大家可以隨意新增角色,並賦予選單許可權,不過不能編輯選單資料哦。筆者為了降低自己的資料不被打亂了

如果幫到了您,還請賞賜一個star,也是我們更文的動力。(PS:寫文章真的要花費不少時間哦?)

相關文章