1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        vite+Vue3+ts搭建通用后臺(tái)管理系統(tǒng)

        共 52469字,需瀏覽 105分鐘

         ·

        2021-08-04 23:46

        通用后臺(tái)管理系統(tǒng)整體架構(gòu)方案(Vue)

        項(xiàng)目創(chuàng)建,腳手架的選擇(vite or vue-cli)

        • vue-cli基于webpack封裝,生態(tài)非常強(qiáng)大,可配置性也非常高,幾乎能夠滿足前端工程化的所有要求。缺點(diǎn)就是配置復(fù)雜,甚至有公司有專門的webpack工程師專門做配置,另外就是webpack由于開發(fā)環(huán)境需要打包編譯,開發(fā)體驗(yàn)實(shí)際上不如vite
        • vite開發(fā)模式基于esbuild,打包使用的是rollup。急速的冷啟動(dòng)和無縫的hmr在開發(fā)模式下獲得極大的體驗(yàn)提升。缺點(diǎn)就是該腳手架剛起步,生態(tài)上還不及webpack。

        本文主要講解使用vite來作為腳手架開發(fā)。(動(dòng)手能力強(qiáng)的小伙伴完全可以使用vite做開發(fā)服務(wù)器,使用webpack做打包編譯放到生產(chǎn)環(huán)境)

        為什么選擇vite而不是vue-cli,不論是webpack,parcel,rollup等工具,雖然都極大的提高了前端的開發(fā)體驗(yàn),但是都有一個(gè)問題,就是當(dāng)項(xiàng)目越來越大的時(shí)候,需要處理的js代碼也呈指數(shù)級(jí)增長(zhǎng),打包過程通常需要很長(zhǎng)時(shí)間(甚至是幾分鐘!)才能啟動(dòng)開發(fā)服務(wù)器,體驗(yàn)會(huì)隨著項(xiàng)目越來越大而變得越來越差。

        由于現(xiàn)代瀏覽器都已經(jīng)原生支持es模塊,我們只要使用支持esm的瀏覽器開發(fā),那么是不是我們的代碼就不需要打包了?是的,原理就是這么簡(jiǎn)單。vite將源碼模塊的請(qǐng)求會(huì)根據(jù)304 Not Modified進(jìn)行協(xié)商緩存,依賴模塊通過Cache-Control:max-age=31536000,immutable進(jìn)行協(xié)商緩存,因此一旦被緩存它們將不需要再次請(qǐng)求。

        軟件巨頭微軟周三(5月19日)表示,從2022年6月15日起,公司某些版本的Windows軟件將不再支持當(dāng)前版本的IE 11桌面應(yīng)用程序。所以利用瀏覽器的最新特性來開發(fā)項(xiàng)目是趨勢(shì)。

        $ npm init @vitejs/app <project-name>
        $ cd <project-name>
        $ npm install
        $ npm run dev

        基礎(chǔ)設(shè)置,代碼規(guī)范的支持(eslint+prettier)

        vscode 安裝 eslint,prettier,vetur(喜歡用vue3 setup語(yǔ)法糖可以使用volar,這時(shí)要禁用vetur)

        打開vscode eslint

        eslint
        yarn add --dev eslint eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin
        prettier
        yarn add --dev prettier eslint-config-prettier eslint-plugin-prettier
        .prettierrc.js
        module.exports = {
            printWidth180//一行的字符數(shù),如果超過會(huì)進(jìn)行換行,默認(rèn)為80
            tabWidth4//一個(gè)tab代表幾個(gè)空格數(shù),默認(rèn)為80
            useTabsfalse//是否使用tab進(jìn)行縮進(jìn),默認(rèn)為false,表示用空格進(jìn)行縮減
            singleQuotetrue//字符串是否使用單引號(hào),默認(rèn)為false,使用雙引號(hào)
            semifalse//行位是否使用分號(hào),默認(rèn)為true
            trailingComma'none'//是否使用尾逗號(hào),有三個(gè)可選值"<none|es5|all>"
            bracketSpacingtrue//對(duì)象大括號(hào)直接是否有空格,默認(rèn)為true,效果:{ foo: bar }
            jsxSingleQuotetrue// jsx語(yǔ)法中使用單引號(hào)
            endOfLine'auto'
        }
        .eslintrc.js
        //.eslintrc.js
        module.exports = {
            parser'vue-eslint-parser',
            parserOptions: {
                parser'@typescript-eslint/parser'// Specifies the ESLint parser
                ecmaVersion2020// Allows for the parsing of modern ECMAScript features
                sourceType'module'// Allows for the use of imports
                ecmaFeatures: {
                    jsxtrue
                }
            },
            extends: [
                'plugin:vue/vue3-recommended',
                'plugin:@typescript-eslint/recommended',
                'prettier',
                'plugin:prettier/recommended'
            ]
        }
        .settings.json(工作區(qū))
        {
            "editor.codeActionsOnSave": {
                "source.fixAll.eslint"true
            },
            "eslint.validate": [
                "javascript",
                "javascriptreact",
                "vue",
                "typescript",
                "typescriptreact",
                "json"
            ]
        }

        目錄結(jié)構(gòu)范例

        ├─.vscode           // vscode配置文件
        ├─public            // 無需編譯的靜態(tài)資源目錄
        ├─src                // 代碼源文件目錄
        │  ├─apis            // apis統(tǒng)一管理
        │  │  └─modules        // api模塊
        │  ├─assets            // 靜態(tài)資源
        │  │  └─images      
        │  ├─components     // 項(xiàng)目組件目錄
        │  │  ├─Form
        │  │  ├─Input
        │  │  ├─Message
        │  │  ├─Search
        │  │  ├─Table
        │  ├─directives     // 指令目錄
        │  │  └─print
        │  ├─hooks            // hooks目錄
        │  ├─layouts        // 布局組件
        │  │  ├─dashboard
        │  │  │  ├─content
        │  │  │  ├─header
        │  │  │  └─sider
        │  │  └─fullpage
        │  ├─mock           // mock apu存放地址,和apis對(duì)應(yīng)
        │  │  └─modules
        │  ├─router            // 路由相關(guān)
        │  │  └─helpers
        │  ├─store            // 狀態(tài)管理相關(guān)
        │  ├─styles            // 樣式相關(guān)(后面降到css架構(gòu)會(huì)涉及具體的目錄)
        │  ├─types            // 類型定義相關(guān)
        │  ├─utils            // 工具類相關(guān)
        │  └─views            // 頁(yè)面目錄地址
        │      ├─normal    
        │      └─system
        └─template            // 模板相關(guān)
            ├─apis
            └─page

        CSS架構(gòu)之ITCSS + BEM + ACSS

        現(xiàn)實(shí)開發(fā)中,我們經(jīng)常忽視CSS的架構(gòu)設(shè)計(jì)。前期對(duì)樣式架構(gòu)的忽略,隨著項(xiàng)目的增大,導(dǎo)致出現(xiàn)樣式污染,覆蓋,難以追溯,代碼重復(fù)等各種問題。因此,CSS架構(gòu)設(shè)計(jì)同樣需要重視起來。

        • ITCSS
          ITCSS是CSS設(shè)計(jì)方法論,它并不是具體的CSS約束,他可以讓你更好的管理、維護(hù)你的項(xiàng)目的 CSS。
        image.png

        ITCSS 把 CSS 分成了以下的幾層

        Layer作用
        Settings項(xiàng)目使用的全局變量
        Toolsmixin,function
        Generic最基本的設(shè)定 normalize.css,reset
        Basetype selector
        Objects不經(jīng)過裝飾 (Cosmetic-free) 的設(shè)計(jì)模式
        ComponentsUI 組件
        Trumpshelper 唯一可以使用 important! 的地方

        以上是給的范式,我們不一定要完全按照它的方式,可以結(jié)合BEMACSS

        目前我給出的CSS文件目錄(暫定)
        └─styles

        ├───acss
        ├───generic
        ├───theme
        ├───tools
        └───transition 
        • BEM
          即Block, Element, Modifier,是OOCSS(面向?qū)ο骳ss)的進(jìn)階版, 它是一種基于組件的web開發(fā)方法。blcok可以理解成獨(dú)立的塊,在頁(yè)面中該塊的移動(dòng)并不會(huì)影響到內(nèi)部樣式(和組件的概念類似,獨(dú)立的一塊),element就是塊下面的元素,和塊有著藕斷絲連的關(guān)系,modifier是表示樣式大小等。
          我們來看一下element-ui的做法
        image.png

        image.png

        我們項(xiàng)目組件的開發(fā)或者封裝統(tǒng)一使用BEM

        • ACSS
          了解tailwind的人應(yīng)該對(duì)此設(shè)計(jì)模式不陌生,即原子級(jí)別的CSS。像.fr,.clearfix這種都屬于ACSS的設(shè)計(jì)思維。此處我們可以用此模式寫一些變量等。

        JWT(json web token)

        JWT是一種跨域認(rèn)證解決方案
        http請(qǐng)求是無狀態(tài)的,服務(wù)器是不認(rèn)識(shí)前端發(fā)送的請(qǐng)求的。比如登錄,登錄成功之后服務(wù)端會(huì)生成一個(gè)sessionKey,sessionKey會(huì)寫入Cookie,下次請(qǐng)求的時(shí)候會(huì)自動(dòng)帶入sessionKey,現(xiàn)在很多都是把用戶ID寫到cookie里面。這是有問題的,比如要做單點(diǎn)登錄,用戶登錄A服務(wù)器的時(shí)候,服務(wù)器生成sessionKey,登錄B服務(wù)器的時(shí)候服務(wù)器沒有sessionKey,所以并不知道當(dāng)前登錄的人是誰(shuí),所以sessionKey做不到單點(diǎn)登錄。但是jwt由于是服務(wù)端生成的token給客戶端,存在客戶端,所以能實(shí)現(xiàn)單點(diǎn)登錄。

        特點(diǎn)
        • 由于使用的是json傳輸,所以JWT是跨語(yǔ)言的

        • 便于傳輸,jwt的構(gòu)成非常簡(jiǎn)單,字節(jié)占用很小,所以它是非常便于傳輸?shù)?/p>

        • jwt會(huì)生成簽名,保證傳輸安全

        • jwt具有時(shí)效性

        • jwt更高效利用集群做好單點(diǎn)登錄

          數(shù)據(jù)結(jié)構(gòu)
        • Header.Payload.Signature

        image.png
        數(shù)據(jù)安全
        • 不應(yīng)該在jwt的payload部分存放敏感信息,因?yàn)樵摬糠质强蛻舳丝山饷艿牟糠?/p>

        • 保護(hù)好secret私鑰,該私鑰非常重要

        • 如果可以,請(qǐng)使用https協(xié)議

          使用流程
          image.png
          使用方式
        • 后端

          const router = require('koa-router')()
          const jwt = require('jsonwebtoken')

          router.post('/login'async (ctx) => {
            try {
                const { userName, userPwd } = ctx.request.body
                const res = await User.findOne({
                    userName,
                    userPwd
                })
                const data = res._doc
                const token = jwt.sign({
                    data
                }, 'secret', { expiresIn'1h' })
                if(res) {
                    data.token = token
                    ctx.body = data
                }
            } catch(e) {
                
            }
            
          } )
        • 前端

          // axios請(qǐng)求攔截器,Cookie寫入token,請(qǐng)求頭添加:Authorization: Bearer `token`
          service.interceptors.request.use(
            request => {
                const token = Cookies.get('token'// 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ'
                token && (request.headers['Authorization'] = token)
                return request
            },
            error => { 
                Message.error(error)
            }
          )
        • 后端驗(yàn)證有效性

          const app = new Koa()
          const router = require('koa-router')()
          const jwt = require('jsonwebtoken')
          const koajwt = require('koa-jwt')
          // 使用koa-jwt中間件不用在接口之前攔截進(jìn)行校驗(yàn)
          app.use(koajwt({ secret:'secret' }))
          // 驗(yàn)證不通過會(huì)將http狀態(tài)碼返回401
          app.use(async (ctx, next) => {
            await next().catch(err => {
                if(err.status === 401) {
                    ctx.body.msg = 'token認(rèn)證失敗'
                }
            })
          })

        菜單設(shè)計(jì)

        關(guān)于菜單的生成方式有很多種,比較傳統(tǒng)的是前端維護(hù)一個(gè)菜單樹,根據(jù)后端返回的菜單樹進(jìn)行過濾。這種方式實(shí)際上提前將路由注冊(cè)進(jìn)入到實(shí)例中,這種現(xiàn)在其實(shí)已經(jīng)不是最佳實(shí)踐了。

        現(xiàn)在主流的思路是后端通過XML來配置菜單,通過配置來生成菜單。前端登錄的時(shí)候拉取該角色對(duì)應(yīng)的菜單,通過addroute方法注冊(cè)菜單相應(yīng)的路由地址以及頁(yè)面在前端項(xiàng)目中的路徑等。這是比較主流的,但是我個(gè)人覺得不算最完美。
        我們菜單和前端代碼其實(shí)是強(qiáng)耦合的,包括路由地址,頁(yè)面路徑,圖標(biāo),重定向等。項(xiàng)目初期菜單可能是經(jīng)常變化的,每次對(duì)菜單進(jìn)行添加或者修改等操作的時(shí)候,需要通知后端修改XML,并且后端的XML實(shí)際上就是沒有樹結(jié)構(gòu),看起來也不是很方便。

        因此我采用如下設(shè)計(jì)模式,**前端**維護(hù)一份menu.json,所寫即所得,json數(shù)是什么樣在菜單配置的時(shí)候就是什么樣。

        結(jié)構(gòu)設(shè)計(jì)
        keytypedescription
        titlestring菜單的標(biāo)題
        namestring對(duì)應(yīng)路由的name,也是頁(yè)面或者按鈕的唯一標(biāo)識(shí),重要,看下面注意事項(xiàng)
        typestringMODULE代表模塊(子系統(tǒng),例如APP和后臺(tái)管理系統(tǒng)),MENU代表菜單,BUTTON代表按鈕
        pathstring路徑,對(duì)應(yīng)路由的path
        redirectstring重定向,對(duì)應(yīng)路由的redirect
        iconstring菜單或者按鈕的圖標(biāo)
        componentstring當(dāng)作為才當(dāng)?shù)臅r(shí)候,對(duì)應(yīng)菜單的項(xiàng)目加載地址
        hiddenboolean當(dāng)作為菜單的時(shí)候是否在左側(cè)菜單樹隱藏
        noCacheboolean當(dāng)作為菜單的時(shí)候該菜單是否緩存
        fullscreenboolean當(dāng)作為菜單的時(shí)候是否全屏顯示當(dāng)前菜單
        childrenarray顧名思義,下一級(jí)

        注意事項(xiàng):同級(jí)的name要是唯一的,實(shí)際使用中,每一級(jí)的name都是通過上一級(jí)的name用-拼接而來(會(huì)通過動(dòng)態(tài)導(dǎo)入章節(jié)演示name的生成規(guī)則),這樣可以保證每一個(gè)菜單或者按鈕項(xiàng)都有唯一的標(biāo)識(shí)。后續(xù)不論是做按鈕權(quán)限控制還是做菜單的緩存,都與此拼接的name有關(guān)。我們注意此時(shí)沒有id,后續(xù)會(huì)講到根據(jù)name全稱使用md5來生成id。

        示例代碼

        [
            {
                "title""admin",
                "name""admin",
                "type""MODULE",
                "children": [
                    {
                        "title""中央控制臺(tái)",
                        "path""/platform",
                        "name""platform",
                        "type""MENU",
                        "component""/platform/index",
                        "icon""mdi:monitor-dashboard"
                    },
                    {
                        "title""系統(tǒng)設(shè)置",
                        "name""system",
                        "type""MENU",
                        "path""/system",
                        "icon""ri:settings-5-line",
                        "children": [
                            {
                                "title""用戶管理",
                                "name""user",
                                "type""MENU",
                                "path""user",
                                "component""/system/user"
                            },
                            {
                                "title""角色管理",
                                "name""role",
                                "type""MENU",
                                "path""role",
                                "component""/system/role"
                            },
                            {
                                "title""資源管理",
                                "name""resource",
                                "type""MENU",
                                "path""resource",
                                "component""/system/resource"
                            }
                        ]
                    },
                    {
                        "title""實(shí)用功能",
                        "name""function",
                        "type""MENU",
                        "path""/function",
                        "icon""ri:settings-5-line",
                        "children": []
                    }
                ]
            }

        生成的菜單樹

        如果覺得所有頁(yè)面的路由寫在一個(gè)頁(yè)面中太長(zhǎng),難以維護(hù)的話,可以把json換成js用import機(jī)制,這里涉及到的變動(dòng)比較多,暫時(shí)先不提及

        使用時(shí),我們分developmentproduction兩種環(huán)境

        • development:該模式下,菜單樹直接讀取menu.json文件
        • production:該模式下,菜單樹通過接口獲取數(shù)據(jù)庫(kù)的數(shù)據(jù)
        如何存到數(shù)據(jù)庫(kù)

        OK,我們之前提到過,菜單是由前端通過menu.json來維護(hù)的,那怎么進(jìn)到數(shù)據(jù)庫(kù)中呢?實(shí)際上,我的設(shè)計(jì)是通過node讀取menu.json文件,然后創(chuàng)建SQL語(yǔ)句,交給后端放到liquibase中,這樣不管有多少個(gè)數(shù)據(jù)庫(kù)環(huán)境,后端只要拿到該SQL語(yǔ)句,就能在多個(gè)環(huán)境創(chuàng)建菜單數(shù)據(jù)。當(dāng)然,由于json是可以跨語(yǔ)言通信的,所以我們可以直接把json文件丟給后端,或者把項(xiàng)目json路徑丟給運(yùn)維,通過CI/CD工具完成自動(dòng)發(fā)布。

        nodejs生成SQL示例

        // createMenu.js
        /**
         *
         * =================MENU CONFIG======================
         *
         * this javascript created to genarate SQL for Java
         *
         * ====================================================
         *
         */


        const fs = require('fs')
        const path = require('path')
        const chalk = require('chalk')
        const execSync = require('child_process').execSync //同步子進(jìn)程
        const resolve = (dir) => path.join(__dirname, dir)
        const moment = require('moment')
        // get the Git user name to trace who exported the SQL
        const gitName = execSync('git show -s --format=%cn').toString().trim()
        const md5 = require('md5')
        // use md5 to generate id

        /* =========GLOBAL CONFIG=========== */

        // 導(dǎo)入路徑
        const INPUT_PATH = resolve('src/router/menu.json')
        // 導(dǎo)出的文件目錄位置
        const OUTPUT_PATH = resolve('./menu.sql')
        // 表名
        const TABLE_NAME = 't_sys_menu'

        /* =========GLOBAL CONFIG=========== */

        function createSQL(data, name = '', pid, 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 || md5(v.name), // name is unique,so we can use name to generate id
                    created_at: moment().format('YYYY-MM-DD HH:mm:ss'),
                    modified_at: moment().format('YYYY-MM-DD HH:mm:ss'),
                    created_by: gitName,
                    modified_by: gitName,
                    version1,
                    is_deletefalse,
                    code: (name + '-' + v.name).slice(1),
                    name: v.name,
                    title: v.title,
                    icon: v.icon,
                    path: v.path,
                    sort: d + 1,
                    parent_id: pid,
                    type: v.type,
                    component: v.component,
                    redirect: v.redirect,
                    full_screen: v.fullScreen || false
                    hidden: v.hidden || false,
                    no_cache: v.noCache || false
                })
            })
            return arr
        }

        fs.readFile(INPUT_PATH, 'utf-8', (err, data) => {
            if (err) chalk.red(err)
            const menuList = createSQL(JSON.parse(data))
            const sql = menuList
                .map((sql) => {
                    let value = ''
                    for (const v of Object.values(sql)) {
                        value += ','
                        if (v === true) {
                            value += 1
                        } else if (v === false) {
                            value += 0
                        } else {
                            value += v ? `'${v}'` : null
                        }
                    }
                    return 'INSERT INTO `' + TABLE_NAME + '` VALUES (' + value.slice(1) + ')' + '\n'
                })
                .join(';')
            const mySQL =
                'DROP TABLE IF EXISTS `' +
                TABLE_NAME +
                '`;' +
                '\n' +
                'CREATE TABLE `' +
                TABLE_NAME +
                '` (' +
                '\n' +
                '`id` varchar(64) NOT NULL,' +
                '\n' +
                "`created_at` timestamp NULL DEFAULT NULL COMMENT '創(chuàng)建時(shí)間'," +
                '\n' +
                "`modified_at` timestamp NULL DEFAULT NULL COMMENT '更新時(shí)間'," +
                '\n' +
                "`created_by` varchar(64) DEFAULT NULL COMMENT '創(chuàng)建人'," +
                '\n' +
                "`modified_by` varchar(64) DEFAULT NULL COMMENT '更新人'," +
                '\n' +
                "`version` int(11) DEFAULT NULL COMMENT '版本(樂觀鎖)'," +
                '\n' +
                "`is_delete` int(11) DEFAULT NULL COMMENT '邏輯刪除'," +
                '\n' +
                "`code` varchar(150) NOT NULL COMMENT '編碼'," +
                '\n' +
                "`name` varchar(50) DEFAULT NULL COMMENT '名稱'," +
                '\n' +
                "`title` varchar(50) DEFAULT NULL COMMENT '標(biāo)題'," +
                '\n' +
                "`icon` varchar(50) DEFAULT NULL COMMENT '圖標(biāo)'," +
                '\n' +
                "`path` varchar(250) DEFAULT NULL COMMENT '路徑'," +
                '\n' +
                "`sort` int(11) DEFAULT NULL COMMENT '排序'," +
                '\n' +
                "`parent_id` varchar(64) DEFAULT NULL COMMENT '父id'," +
                '\n' +
                "`type` char(10) DEFAULT NULL COMMENT '類型'," +
                '\n' +
                "`component` varchar(250) DEFAULT NULL COMMENT '組件路徑'," +
                '\n' +
                "`redirect` varchar(250) DEFAULT NULL COMMENT '重定向路徑'," +
                '\n' +
                "`full_screen` int(11) DEFAULT NULL COMMENT '全屏'," +
                '\n' +
                "`hidden` int(11) DEFAULT NULL COMMENT '隱藏'," +
                '\n' +
                "`no_cache` int(11) 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(OUTPUT_PATH, mySQL, (err) => {
                if (err) return chalk.red(err)
                console.log(chalk.cyanBright(`恭喜你,創(chuàng)建sql語(yǔ)句成功,位置:${OUTPUT_PATH}`))
            })
        }) 

        注意上面是通過使用md5對(duì)name進(jìn)行加密生成主鍵id到數(shù)據(jù)庫(kù)中

        我們嘗試用node執(zhí)行該js

        node createMenu.js
        image.png
        image.png

        由于生產(chǎn)環(huán)境不會(huì)直接引入menu.json,因此經(jīng)過打包編譯的線上環(huán)境不會(huì)存在該文件,因此也不會(huì)有安全性問題

        如何控制到按鈕級(jí)別

        我們知道,按鈕(這里的按鈕是廣義上的,對(duì)于前端來說可能是button,tab,dropdown等一切可以控制的內(nèi)容)的載體一定是頁(yè)面,因此按鈕可以直接掛在到menu樹的MENU類型的資源下面,沒有頁(yè)面頁(yè)面權(quán)限當(dāng)然沒有該頁(yè)面下的按鈕權(quán)限,有頁(yè)面權(quán)限的情況下,我們通過v-permission指令來控制按鈕的顯示
        示例代碼

        // 生成權(quán)限按鈕表存到store
        const createPermissionBtns = router => {
            let btns = []
            const c = (router, name = '') => {
                router.forEach(v => {
                    v.type === 'BUTTON' && 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)
        }
        // 權(quán)限控制
        Vue.directive('permission', {
            // 這里是vue3的寫法,vue2請(qǐng)使用inserted生命周期
            mounted(el, binding, vnode) {
                // 獲取this
                const { context: vm } = vnode
                // 獲取綁定的值
                const name = vm.$options.name + '-' + binding.value
                // 獲取權(quán)限表
                const {
                    state: { permissionBtns }
                } = store
                // 如果沒有權(quán)限那就移除
                if (permissionBtns.indexOf(name) === -1) {
                    el.parentNode.removeChild(el)
                }
            }
        })
        <el-button type="text" v-permission="'edit'" @click="edit(row.id)">編輯</el-button>

        假設(shè)當(dāng)前頁(yè)面的name值是system-role,按鈕的name值是system-role-edit,那么通過此指令就可以很方便的控制到按鈕的權(quán)限

        動(dòng)態(tài)導(dǎo)入

        我們json或者接口配置的路由前端頁(yè)面地址,在vue-router中又是如何注冊(cè)進(jìn)去的呢?

        注意以下name的生成規(guī)則,以角色菜單為例,name拼接出的形式大致為:

        • 一級(jí)菜單:system
        • 二級(jí)菜單:system-role
        • 該二級(jí)菜單下的按鈕:system-role-edit
        • vue-cli vue-cli3及以上可以直接使用 webpack4+引入的dynamic import

          // 生成可訪問的路由表
          const generateRoutes = (routes, cname = '') => {
            return routes.reduce((prev, { type, uri: path, componentPath, name, title, icon, redirectUri: redirect, hidden, fullScreen, noCache, children = [] }) => {
                // 是菜單項(xiàng)就注冊(cè)到路由進(jìn)去
                if (type === 'MENU') {
                    prev.push({
                        path,
                        component() => import(`@/${componentPath}`),
                        name: (cname + '-' + name).slice(1),
                        propstrue,
                        redirect,
                        meta: { title, icon, hidden, type, fullScreen, noCache },
                        children: children.length ? createRouter(children, cname + '-' + name) : []
                    })
                }
                return prev
            }, [])
          }
        • vite vite2之后可以直接使用glob-import

          // dynamicImport.ts
          export default function dynamicImport(component: string{
            const dynamicViewsModules = import.meta.glob('../../views/**/*.{vue,tsx}')
            const keys = Object.keys(dynamicViewsModules)
            const matchKeys = keys.filter((key) => {
                const k = key.replace('../../views''')
                return k.startsWith(`${component}`) || k.startsWith(`/${component}`)
            })
            if (matchKeys?.length === 1) {
                const matchKey = matchKeys[0]
                return dynamicViewsModules[matchKey]
            }
            if (matchKeys?.length > 1) {
                console.warn(
                    'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure'
                )
                return
            }
            return null
          }
        import type { IResource, RouteRecordRaw } from '../types'
        import dynamicImport from './dynamicImport'

        // 生成可訪問的路由表
        const generateRoutes = (routes: IResource[], cname = '', level = 1): RouteRecordRaw[] => {
            return routes.reduce((prev: RouteRecordRaw[], curr: IResource) => {
                // 如果是菜單項(xiàng)則注冊(cè)進(jìn)來
                const { id, type, path, component, name, title, icon, redirect, hidden, fullscreen, noCache, children } = curr
                if (type === 'MENU') {
                    // 如果是一級(jí)菜單沒有子菜單,則掛在在app路由下面
                    if (level === 1 && !(children && children.length)) {
                        prev.push({
                            path,
                            component: dynamicImport(component!),
                            name,
                            propstrue,
                            meta: { id, title, icon, type, parentName'app'hidden: !!hidden, fullscreen: !!fullscreen, noCache: !!noCache }
                        })
                    } else {
                        prev.push({
                            path,
                            component: component ? dynamicImport(component) : () => import('/@/layouts/dashboard'),
                            name: (cname + '-' + name).slice(1),
                            propstrue,
                            redirect,
                            meta: { id, title, icon, type, hidden: !!hidden, fullscreen: !!fullscreen, noCache: !!noCache },
                            children: children?.length ? generateRoutes(children, cname + '-' + name, level + 1) : []
                        })
                    }
                }
                return prev
            }, [])
        }

        export default generateRoutes
        動(dòng)態(tài)注冊(cè)路由

        要實(shí)現(xiàn)動(dòng)態(tài)添加路由,即只有有權(quán)限的路由才會(huì)注冊(cè)到Vue實(shí)例中??紤]到每次刷新頁(yè)面的時(shí)候由于vue的實(shí)例會(huì)丟失,并且角色的菜單也可能會(huì)更新,因此在每次加載頁(yè)面的時(shí)候做菜單的拉取和路由的注入是最合適的時(shí)機(jī)。因此核心是vue-routeraddRoute和導(dǎo)航守衛(wèi)beforeEach兩個(gè)方法

        要實(shí)現(xiàn)動(dòng)態(tài)添加路由,即只有有權(quán)限的路由才會(huì)注冊(cè)到Vue實(shí)例中??紤]到每次刷新頁(yè)面的時(shí)候由于vue的實(shí)例會(huì)丟失,并且角色的菜單也可能會(huì)更新,因此在每次加載頁(yè)面的時(shí)候做菜單的拉取和路由的注入是最合適的時(shí)機(jī)。因此核心是vue-router的addRoute和導(dǎo)航鉤子beforeEach兩個(gè)方法

        vue-router3x

        :3.5.0API也更新到了addRoute,注意區(qū)分版本變化

        vue-router4x

        個(gè)人更傾向于使用vue-router4xaddRoute方法,這樣可以更精細(xì)的控制每一個(gè)路由的的定位

        大體思路為,在beforeEach該導(dǎo)航守衛(wèi)中(即每次路由跳轉(zhuǎn)之前做判斷),如果已經(jīng)授權(quán)過(authorized),就直接進(jìn)入next方法,如果沒有,則從后端拉取路由表注冊(cè)到實(shí)例中。(直接在入口文件main.js中引入以下文件或代碼)

        // permission.js
        router.beforeEach(async (to, from, next) => {
            const token = Cookies.get('token')
            if (token) {
                if (to.path === '/login') {
                    next({ path'/' })
                } else {
                    if (!store.state.authorized) {
                        // set authority
                        await store.dispatch('setAuthority')
                        // it's a hack func,avoid bug
                        next({ ...to, replacetrue })
                    } else {
                        next()
                    }
                }
            } else {
                if (to.path !== '/login') {
                    next({ path'/login' })
                } else {
                    next(true)
                }
            }
        })

        由于路由是動(dòng)態(tài)注冊(cè)的,所以項(xiàng)目的初始路由就會(huì)很簡(jiǎn)潔,只要提供靜態(tài)的不需要權(quán)限的基礎(chǔ)路由,其他路由都是從服務(wù)器返回之后動(dòng)態(tài)注冊(cè)進(jìn)來的

        // router.js
        import { createRouter, createWebHistory } from 'vue-router'
        import type { RouteRecordRaw } from './types'

        // static modules
        import Login from '/@/views/sys/Login.vue'
        import NotFound from '/@/views/sys/NotFound.vue'
        import Homepage from '/@/views/sys/Homepage.vue'
        import Layout from '/@/layouts/dashboard'

        const routes: RouteRecordRaw[] = [
            {
                path'/',
                redirect'/homepage'
            },
            {
                path'/login',
                component: Login
            },
            // for 404 page
            {
                path'/:pathMatch(.*)*',
                component: NotFound
            },
            // to place the route who don't have children
            {
                path'/app',
                component: Layout,
                name'app',
                children: [{ path'/homepage'component: Homepage, name'homepage'meta: { title'首頁(yè)' } }]
            }
        ]

        const router = createRouter({
            history: createWebHistory(),
            routes,
            scrollBehavior() {
                // always scroll to top
                return { top0 }
            }
        })
        export default router 
        左側(cè)菜單樹和按鈕生成

        其實(shí)只要遞歸拿到type為MENU的資源注冊(cè)到路由,過濾掉hidden:true的菜單在左側(cè)樹顯示,此處不再贅述。

        RBAC(Role Based Access Control)

        RBAC 是基于角色的訪問控制(Role-Based Access Control )在 RBAC 中,權(quán)限與角色相關(guān)聯(lián),用戶通過成為適當(dāng)角色的成員而得到這些角色的權(quán)限。這就極大地簡(jiǎn)化了權(quán)限的管理。這樣管理都是層級(jí)相互依賴的,權(quán)限賦予給角色,而把角色又賦予用戶,這樣的權(quán)限設(shè)計(jì)很清楚,管理起來很方便。

        這樣登錄的時(shí)候只要獲取用戶

        用戶選擇角色

        角色綁定菜單

        菜單

        頁(yè)面緩存控制

        頁(yè)面緩存,聽起來無關(guān)緊要的功能,卻能給客戶帶來極大的使用體驗(yàn)的提升。
        例如我們有一個(gè)分頁(yè)列表,輸入某個(gè)查詢條件之后篩選出某一條數(shù)據(jù),點(diǎn)開詳情之后跳轉(zhuǎn)到新的頁(yè)面,關(guān)閉詳情返回分頁(yè)列表的頁(yè)面,假如之前查詢的狀態(tài)不存在,用戶需要重復(fù)輸入查詢條件,這不僅消耗用戶的耐心,也增加了服務(wù)器不必要的壓力。

        因此,緩存控制在系統(tǒng)里面很有存在的價(jià)值,我們知道vuekeep-alive組件可以讓我們很方便的進(jìn)行緩存,那么是不是我們直接把根組件直接用keep-alive包裝起來就好了呢?

        實(shí)際上這樣做是不合適的,比如我有個(gè)用戶列表,打開小明和小紅的詳情頁(yè)都給他緩存起來,由于緩存是寫入內(nèi)存的,用戶使用系統(tǒng)久了之后必將導(dǎo)致系統(tǒng)越來越卡。并且類似于詳情頁(yè)這種數(shù)據(jù)應(yīng)該是每次打開的時(shí)候都從接口獲取一次才能保證是最新的數(shù)據(jù),將它也緩存起來本身就是不合適的。那么按需緩存就是我們系統(tǒng)迫切需要使用的,好在keep-alive給我們提供了include這個(gè)api

        image.png

        注意這個(gè)include存的是頁(yè)面的name,不是路由的name

        因此,如何定義頁(yè)面的name是很關(guān)鍵的

        我的做法是,vue頁(yè)面的name值與當(dāng)前的menu.json的層級(jí)相連的name(實(shí)際上經(jīng)過處理就是注冊(cè)路由的時(shí)候的全路徑name)對(duì)應(yīng),參考動(dòng)態(tài)導(dǎo)入的介紹,這樣做用兩個(gè)目的:

        • 我們知道vue的緩存組件keep-aliveinclude選項(xiàng)是基于頁(yè)面的name來緩存的,我們使路由的name和頁(yè)面的name保持一致,這樣我們一旦路由發(fā)生變化,我們將所有路由的name存到store中,也就相當(dāng)于存了頁(yè)面的name到了store中,這樣做緩存控制會(huì)很方便。當(dāng)然頁(yè)面如果不需要緩存,可以在menu.json中給這個(gè)菜單noCache設(shè)置為true,這也是我們菜單表結(jié)構(gòu)中該字段的由來。
        • 我們開發(fā)的時(shí)候一般都會(huì)安裝vue-devtools進(jìn)行調(diào)試,語(yǔ)義化的name值方便進(jìn)行調(diào)試。

        例如角色管理

        對(duì)應(yīng)的json位置

        對(duì)應(yīng)的vue文件

        對(duì)應(yīng)的vue-devtools

        為了更好的用戶體驗(yàn),我們?cè)谙到y(tǒng)里面使用tag來記錄用戶之前點(diǎn)開的頁(yè)面的狀態(tài)。其實(shí)這也是一個(gè)hack手段,無非是解決SPA項(xiàng)目的一個(gè)痛點(diǎn)。

        效果圖

        大概思路就是監(jiān)聽路由變化,把所有路由的相關(guān)信息存到store中。根據(jù)該路由的noCache字段顯示不同的小圖標(biāo),告訴用戶這個(gè)路由是否是帶有緩存的路由。

        組件的封裝或者基于UI庫(kù)的二次封裝

        組件的封裝原則無非就是復(fù)用,可擴(kuò)展。

        我們?cè)谧畛醴庋b組件的時(shí)候不用追求過于完美,滿足基礎(chǔ)的業(yè)務(wù)場(chǎng)景即可。后續(xù)根據(jù)需求變化再去慢慢完善組件。

        如果是多人團(tuán)隊(duì)的大型項(xiàng)目還是建議使用Jest做好單元測(cè)試配合storybook生成組件文檔。

        關(guān)于組件的封裝技巧,網(wǎng)上有很多詳細(xì)的教程,本人經(jīng)驗(yàn)有限,這里就不再討論。

        使用plop創(chuàng)建模板

        基本框架搭建完畢,組件也封裝好了之后,剩下的就是碼業(yè)務(wù)功能了。
        對(duì)于中后臺(tái)管理系統(tǒng),業(yè)務(wù)部分大部分離不開CRUD,我們看到上面的截圖,類似用戶,角色等菜單,組成部分都大同小異,前端部分只要封裝好組件(列表,表單,彈框等),頁(yè)面都可以直接通過模板來生成。甚至現(xiàn)在有很多可視化配置工具(低代碼),我個(gè)人覺得目前不太適合專業(yè)前端,因?yàn)楹芏鄨?chǎng)景下頁(yè)面的組件都是基于業(yè)務(wù)封裝的,單純的把UI庫(kù)原生組件搬過來沒有意義。當(dāng)然時(shí)間充足的話,可以自己在項(xiàng)目上用node開發(fā)低代碼的工具。

        這里我們可以配合inquirer-directory來在控制臺(tái)選擇目錄

        • plopfile.js

          const promptDirectory = require('inquirer-directory')
          const pageGenerator = require('./template/page/prompt')
          const apisGenerator = require('./template/apis/prompt')
          module.exports = function (plop{
            plop.setPrompt('directory', promptDirectory)
            plop.setGenerator('page', pageGenerator)
            plop.setGenerator('apis', apisGenerator)
          }

        一般情況下, 我們和后臺(tái)定義好restful規(guī)范的接口之后,每當(dāng)有新的業(yè)務(wù)頁(yè)面的時(shí)候,我們要做兩件事情,一個(gè)是寫好接口配置,一個(gè)是寫頁(yè)面,這兩個(gè)我們可以通過模板來創(chuàng)建了。我們使用hbs來創(chuàng)建。

        • api.hbs
        import request from '../request'
        {{#if create}}
        // Create
        export const create{{ properCase name }} = (data: any) => request.post('{{camelCase name}}/', data)
        {{/if}}
        {{#if delete}}
        // Delete
        export const remove{{ properCase name }} = (id: string) => request.delete(`{{camelCase name}}/${id}`)
        {{/if}}
        {{#if update}}
        // Update
        export const update{{ properCase name }} = (id: string, data: any) => request.put(`{{camelCase name}}/${id}`, data)
        {{/if}}
        {{#if get}}
        // Retrieve
        export const get{{ properCase name }} = (id: string) => request.get(`{{camelCase name}}/${id}`)
        {{/if}}
        {{#if check}}
        // Check Unique
        export const check{{ properCase name }} = (data: any) => request.post(`{{camelCase name}}/check`, data)
        {{/if}}
        {{#if fetchList}}
        // List query
        export const fetch{{ properCase name }}List = (params: any) => request.get('{{camelCase name}}/list', { params })
        {{/if}}
        {{#if fetchPage}}
        // Page query
        export const fetch{{ properCase name }}Page = (params: any) => request.get('{{camelCase name}}/page', { params })
        {{/if}} 
        • prompt.js

          const { notEmpty } = require('../utils.js')

          const path = require('path')

          // 斜杠轉(zhuǎn)駝峰
          function toCamel(str{
            return str.replace(/(.*)\/(\w)(.*)/gfunction (_, $1, $2, $3{
                return $1 + $2.toUpperCase() + $3
            })
          }
          // 選項(xiàng)框
          const choices = ['create''update''get''delete''check''fetchList''fetchPage'].map((type) => ({
            name: type,
            value: type,
            checkedtrue
          }))

          module.exports = {
            description'generate api template',
            prompts: [
                {
                    type'directory',
                    name'from',
                    message'Please select the file storage address',
                    basePath: path.join(__dirname, '../../src/apis')
                },
                {
                    type'input',
                    name'name',
                    message'api name',
                    validate: notEmpty('name')
                },
                {
                    type'checkbox',
                    name'types',
                    message'api types',
                    choices
                }
            ],
            actions(data) => {
                const { from, name, types } = data
                const actions = [
                    {
                        type'add',
                        path: path.join('src/apis'from, toCamel(name) + '.ts'),
                        templateFile'template/apis/index.hbs',
                        data: {
                            name,
                            create: types.includes('create'),
                            update: types.includes('update'),
                            get: types.includes('get'),
                            check: types.includes('check'),
                            delete: types.includes('delete'),
                            fetchList: types.includes('fetchList'),
                            fetchPage: types.includes('fetchPage')
                        }
                    }
                ]

                return actions
            }

        我們來執(zhí)行plop

        通過inquirer-directory,我們可以很方便的選擇系統(tǒng)目錄

        輸入name名,一般對(duì)應(yīng)后端的controller名稱

        使用空格來選擇每一項(xiàng),使用回車來確認(rèn)

        最終生成的文件

        生成頁(yè)面的方式與此類似,我這邊也只是拋磚引玉,相信大家能把它玩出花來

        項(xiàng)目地址levi-vue-admin:https://github.com/sky124380729/levi-vue-admin
        作者:sky124380729

        https://segmentfault.com/a/1190000040096254


        點(diǎn)贊和在看就是最大的支持

        瀏覽 43
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            超碰综合 | 亚洲天堂在线观看网站 | 91乱码少妇在线观看 | 成人福利观看 | 免费A级毛片无码无遮挡 | 大鸡吧插小穴 | 亚洲天堂777 | 麻豆一级片 | 双人操逼国产精选 | 欧美一卡二卡在线播放 |