vite+vue3+ts搭建通用後臺管理系統

通用後臺管理系統總體架構方案(Vue)

項目建立,腳手架的選擇(vite or vue-cli)

  • vue-cli基於webpack封裝,生態很是強大,可配置性也很是高,幾乎可以知足前端工程化的全部要求。缺點就是配置複雜,甚至有公司有專門的webpack工程師專門作配置,另外就是webpack因爲開發環境須要打包編譯,開發體驗實際上不如vite
  • vite開發模式基於esbuild,打包使用的是rollup。急速的冷啓動和無縫的hmr在開發模式下得到極大的體驗提高。缺點就是該腳手架剛起步,生態上還不及webpack

本文主要講解使用vite來做爲腳手架開發。(動手能力強的小夥伴徹底可使用vite作開發服務器,使用webpack作打包編譯放到生產環境)javascript

爲何選擇vite而不是vue-cli,不管是webpack,parcel,rollup等工具,雖然都極大的提升了前端的開發體驗,可是都有一個問題,就是當項目愈來愈大的時候,須要處理的js代碼也呈指數級增加,打包過程一般須要很長時間(甚至是幾分鐘!)才能啓動開發服務器,體驗會隨着項目愈來愈大而變得愈來愈差。css

因爲現代瀏覽器都已經原生支持es模塊,咱們只要使用支持esm的瀏覽器開發,那麼是否是咱們的代碼就不須要打包了?是的,原理就是這麼簡單。vite將源碼模塊的請求會根據304 Not Modified進行協商緩存,依賴模塊經過Cache-Control:max-age=31536000,immutable進行協商緩存,所以一旦被緩存它們將不須要再次請求。html

軟件巨頭微軟週三(5月19日)表示,從2022年6月15日起,公司某些版本的Windows軟件將再也不支持當前版本的IE 11桌面應用程序。 因此利用瀏覽器的最新特性來開發項目是趨勢。
$ npm init @vitejs/app <project-name>
$ cd <project-name>
$ npm install
$ npm run dev

基礎設置,代碼規範的支持(eslint+prettier)

vscode 安裝 eslint,prettier,vetur(喜歡用vue3 setup語法糖可使用volar,這時要禁用vetur)前端

打開vscode eslint
image.pngvue

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 = {
    printWidth: 180, //一行的字符數,若是超過會進行換行,默認爲80
    tabWidth: 4, //一個tab表明幾個空格數,默認爲80
    useTabs: false, //是否使用tab進行縮進,默認爲false,表示用空格進行縮減
    singleQuote: true, //字符串是否使用單引號,默認爲false,使用雙引號
    semi: false, //行位是否使用分號,默認爲true
    trailingComma: 'none', //是否使用尾逗號,有三個可選值"<none|es5|all>"
    bracketSpacing: true, //對象大括號直接是否有空格,默認爲true,效果:{ foo: bar }
    jsxSingleQuote: true, // jsx語法中使用單引號
    endOfLine: 'auto'
}
.eslintrc.js
//.eslintrc.js
module.exports = {
    parser: 'vue-eslint-parser',
    parserOptions: {
        parser: '@typescript-eslint/parser', // Specifies the ESLint parser
        ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
        sourceType: 'module', // Allows for the use of imports
        ecmaFeatures: {
            jsx: true
        }
    },
    extends: [
        'plugin:vue/vue3-recommended',
        'plugin:@typescript-eslint/recommended',
        'prettier',
        'plugin:prettier/recommended'
    ]
}
.settings.json(工做區)
{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
    "eslint.validate": [
        "javascript",
        "javascriptreact",
        "vue",
        "typescript",
        "typescriptreact",
        "json"
    ]
}

目錄結構範例

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

CSS架構之ITCSS + BEM + ACSS

現實開發中,咱們常常忽視CSS的架構設計。前期對樣式架構的忽略,隨着項目的增大,致使出現樣式污染,覆蓋,難以追溯,代碼重複等各類問題。所以,CSS架構設計一樣須要重視起來。java

  • ITCSS
    ITCSS是CSS設計方法論,它並非具體的CSS約束,他可讓你更好的管理、維護你的項目的 CSS。

image.png

ITCSS 把 CSS 分紅了如下的幾層node

Layer 做用
Settings 項目使用的全局變量
Tools mixin,function
Generic 最基本的設定 normalize.css,reset
Base type selector
Objects 不通過裝飾 (Cosmetic-free) 的設計模式
Components UI 組件
Trumps helper 惟一可使用 important! 的地方

以上是給的範式,咱們不必定要徹底按照它的方式,能夠結合BEMACSSreact

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

├───acss
├───base
├───settings
├───theme
└───tools
  • BEM
    即Block, Element, Modifier,是OOCSS(面向對象css)的進階版, 它是一種基於組件的web開發方法。blcok能夠理解成獨立的塊,在頁面中該塊的移動並不會影響到內部樣式(和組件的概念相似,獨立的一塊),element就是塊下面的元素,和塊有着藕斷絲連的關係,modifier是表示樣式大小等。
    咱們來看一下element-ui的作法

image.png


image.png

咱們項目組件的開發或者封裝統一使用BEMios

  • ACSS
    瞭解tailwind的人應該對此設計模式不陌生,即原子級別的CSS。像.fr,.clearfix這種都屬於ACSS的設計思惟。此處咱們能夠用此模式寫一些變量等。

JWT(json web token)

JWT是一種跨域認證解決方案
http請求是無狀態的,服務器是不認識前端發送的請求的。好比登陸,登陸成功以後服務端會生成一個sessionKey,sessionKey會寫入Cookie,下次請求的時候會自動帶入sessionKey,如今不少都是把用戶ID寫到cookie裏面。這是有問題的,好比要作單點登陸,用戶登陸A服務器的時候,服務器生成sessionKey,登陸B服務器的時候服務器沒有sessionKey,因此並不知道當前登陸的人是誰,因此sessionKey作不到單點登陸。可是jwt因爲是服務端生成的token給客戶端,存在客戶端,因此能實現單點登陸。

特色
  • 因爲使用的是json傳輸,因此JWT是跨語言的
  • 便於傳輸,jwt的構成很是簡單,字節佔用很小,因此它是很是便於傳輸的
  • jwt會生成簽名,保證傳輸安全
  • jwt具備時效性
  • jwt更高效利用集羣作好單點登陸

    數據結構
  • Header.Payload.Signature

image.png

數據安全
  • 不該該在jwt的payload部分存放敏感信息,由於該部分是客戶端可解密的部分
  • 保護好secret私鑰,該私鑰很是重要
  • 若是能夠,請使用https協議

    使用流程

    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請求攔截器,Cookie寫入token,請求頭添加: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)
      }
    )
  • 後端驗證有效性

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

菜單設計

關於菜單的生成方式有不少種,比較傳統的是前端維護一個菜單樹,根據後端返回的菜單樹進行過濾。這種方式實際上提早將路由註冊進入到實例中,這種如今其實已經不是最佳實踐了。

如今主流的思路是後端經過XML來配置菜單,經過配置來生成菜單。前端登陸的時候拉取該角色對應的菜單,經過addroute方法註冊菜單相應的路由地址以及頁面在前端項目中的路徑等。這是比較主流的,可是我我的以爲不算最完美。
咱們菜單和前端代碼實際上是強耦合的,包括路由地址,頁面路徑,圖標,重定向等。項目初期菜單多是常常變化的,每次對菜單進行添加或者修改等操做的時候,須要通知後端修改XML,而且後端的XML實際上就是沒有樹結構,看起來也不是很方便。

所以我採用以下設計模式,前端維護一份menu.json,所寫即所得,json數是什麼樣在菜單配置的時候就是什麼樣。

結構設計
key type description
title string 菜單的標題
name string 對應路由的name,也是頁面或者按鈕的惟一標識,重要,看下面注意事項
type string MODULE表明模塊(子系統,例如APP和後臺管理系統),MENU表明菜單,BUTTON表明按鈕
path string 路徑,對應路由的path
redirect string 重定向,對應路由的redirect
icon string 菜單或者按鈕的圖標
component string 看成爲才當的時候,對應菜單的項目加載地址
hidden boolean 看成爲菜單的時候是否在左側菜單樹隱藏
noCache boolean 看成爲菜單的時候該菜單是否緩存
fullscreen boolean 看成爲菜單的時候是否全屏顯示當前菜單
children array 顧名思義,下一級
注意事項:同級的name要是惟一的,實際使用中,每一級的name都是經過上一級的name用 -拼接而來(會經過 動態導入章節演示name的生成規則),這樣能夠保證每個菜單或者按鈕項都有惟一的標識。後續不管是作按鈕權限控制仍是作菜單的緩存,都與此拼接的name有關。咱們注意此時沒有id,後續會講到根據name全稱使用md5來生成id。

示例代碼

[
    {
        "title": "admin",
        "name": "admin",
        "type": "MODULE",
        "children": [
            {
                "title": "中央控制檯",
                "path": "/platform",
                "name": "platform",
                "type": "MENU",
                "component": "/platform/index",
                "icon": "mdi:monitor-dashboard"
            },
            {
                "title": "系統設置",
                "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": "實用功能",
                "name": "function",
                "type": "MENU",
                "path": "/function",
                "icon": "ri:settings-5-line",
                "children": []
            }
        ]
    }
]

生成的菜單樹
image.png

若是以爲全部頁面的路由寫在一個頁面中太長,難以維護的話,能夠把 json換成js用 import機制,這裏涉及到的變更比較多,暫時先不說起

使用時,咱們分developmentproduction兩種環境

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

OK,咱們以前提到過,菜單是由前端經過menu.json來維護的,那怎麼進到數據庫中呢?實際上,個人設計是經過node讀取menu.json文件,而後建立SQL語句,交給後端放到liquibase中,這樣無論有多少個數據庫環境,後端只要拿到該SQL語句,就能在多個環境建立菜單數據。固然,因爲json是能夠跨語言通訊的,因此咱們能夠直接把json文件丟給後端,或者把項目json路徑丟給運維,經過CI/CD工具完成自動發佈。

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 //同步子進程
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=========== */

// 導入路徑
const INPUT_PATH = resolve('src/router/menu.json')
// 導出的文件目錄位置
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,
            version: 1,
            is_delete: false,
            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 '建立時間'," +
        '\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` 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 '標題'," +
        '\n' +
        "`icon` varchar(50) DEFAULT NULL COMMENT '圖標'," +
        '\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(`恭喜你,建立sql語句成功,位置:${OUTPUT_PATH}`))
    })
})
注意上面是經過使用 md5name進行加密生成主鍵 id到數據庫中

咱們嘗試用node執行該js

node createMenu.js

image.png

image.png

因爲生產環境不會直接引入menu.json,所以通過打包編譯的線上環境不會存在該文件,所以也不會有安全性問題

如何控制到按鈕級別

咱們知道,按鈕(這裏的按鈕是廣義上的,對於前端來講多是button,tab,dropdown等一切能夠控制的內容)的載體必定是頁面,所以按鈕能夠直接掛在到menu樹的MENU類型的資源下面,沒有頁面頁面權限固然沒有該頁面下的按鈕權限,有頁面權限的狀況下,咱們經過v-permission指令來控制按鈕的顯示
示例代碼

// 生成權限按鈕表存到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)
}
// 權限控制
Vue.directive('permission', {
    // 這裏是vue3的寫法,vue2請使用inserted生命週期
    mounted(el, binding, vnode) {
        // 獲取this
        const { context: vm } = vnode
        // 獲取綁定的值
        const name = vm.$options.name + '-' + binding.value
        // 獲取權限表
        const {
            state: { permissionBtns }
        } = store
        // 若是沒有權限那就移除
        if (permissionBtns.indexOf(name) === -1) {
            el.parentNode.removeChild(el)
        }
    }
})
<el-button type="text" v-permission="'edit'" @click="edit(row.id)">編輯</el-button>

假設當前頁面的name值是system-role,按鈕的name值是system-role-edit,那麼經過此指令就能夠很方便的控制到按鈕的權限

動態導入

咱們json或者接口配置的路由前端頁面地址,在vue-router中又是如何註冊進去的呢?

注意如下name的生成規則,以角色菜單爲例,name拼接出的形式大體爲:

  • 一級菜單:system
  • 二級菜單:system-role
  • 該二級菜單下的按鈕: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 = [] }) => {
          // 是菜單項就註冊到路由進去
          if (type === 'MENU') {
              prev.push({
                  path,
                  component: () => import(`@/${componentPath}`),
                  name: (cname + '-' + name).slice(1),
                  props: true,
                  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) => {
        // 若是是菜單項則註冊進來
        const { id, type, path, component, name, title, icon, redirect, hidden, fullscreen, noCache, children } = curr
        if (type === 'MENU') {
            // 若是是一級菜單沒有子菜單,則掛在在app路由下面
            if (level === 1 && !(children && children.length)) {
                prev.push({
                    path,
                    component: dynamicImport(component!),
                    name,
                    props: true,
                    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),
                    props: true,
                    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
動態註冊路由

要實現動態添加路由,即只有有權限的路由纔會註冊到Vue實例中。考慮到每次刷新頁面的時候因爲vue的實例會丟失,而且角色的菜單也可能會更新,所以在每次加載頁面的時候作菜單的拉取和路由的注入是最合適的時機。所以核心是vue-routeraddRoute和導航守衛beforeEach兩個方法

要實現動態添加路由,即只有有權限的路由纔會註冊到Vue實例中。考慮到每次刷新頁面的時候因爲vue的實例會丟失,而且角色的菜單也可能會更新,所以在每次加載頁面的時候作菜單的拉取和路由的注入是最合適的時機。所以核心是vue-router的addRoute和導航鉤子beforeEach兩個方法

vue-router3x
image.png

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

vue-router4x
image.png

我的更傾向於使用vue-router4xaddRoute方法,這樣能夠更精細的控制每個路由的的定位
image.png

大致思路爲,在beforeEach該導航守衛中(即每次路由跳轉以前作判斷),若是已經受權過(authorized),就直接進入next方法,若是沒有,則從後端拉取路由表註冊到實例中。(直接在入口文件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, replace: true })
            } else {
                next()
            }
        }
    } else {
        if (to.path !== '/login') {
            next({ path: '/login' })
        } else {
            next(true)
        }
    }
})

因爲路由是動態註冊的,因此項目的初始路由就會很簡潔,只要提供靜態的不須要權限的基礎路由,其餘路由都是從服務器返回以後動態註冊進來的

// 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: '首頁' } }]
    }
]

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

其實只要遞歸拿到type爲MENU的資源註冊到路由,過濾掉hidden:true的菜單在左側樹顯示,此處再也不贅述。

RBAC(Role Based Access Control)

RBAC 是基於角色的訪問控制(Role-Based Access Control )在 RBAC 中,權限與角色相關聯,用戶經過成爲適當角色的成員而獲得這些角色的權限。這就極大地簡化了權限的管理。這樣管理都是層級相互依賴的,權限賦予給角色,而把角色又賦予用戶,這樣的權限設計很清楚,管理起來很方便。

這樣登陸的時候只要獲取用戶

用戶選擇角色
image.png

角色綁定菜單
image.png

菜單
image.png

頁面緩存控制

頁面緩存,聽起來可有可無的功能,卻能給客戶帶來極大的使用體驗的提高。
例如咱們有一個分頁列表,輸入某個查詢條件以後篩選出某一條數據,點開詳情以後跳轉到新的頁面,關閉詳情返回分頁列表的頁面,假如以前查詢的狀態不存在,用戶須要重複輸入查詢條件,這不只消耗用戶的耐心,也增長了服務器沒必要要的壓力。

所以,緩存控制在系統裏面頗有存在的價值,咱們知道vuekeep-alive組件可讓咱們很方便的進行緩存,那麼是否是咱們直接把根組件直接用keep-alive包裝起來就行了呢?

實際上這樣作是不合適的,好比我有個用戶列表,打開小明和小紅的詳情頁都給他緩存起來,因爲緩存是寫入內存的,用戶使用系統久了以後必將致使系統愈來愈卡。而且相似於詳情頁這種數據應該是每次打開的時候都從接口獲取一次才能保證是最新的數據,將它也緩存起來自己就是不合適的。那麼按需緩存就是咱們系統迫切須要使用的,好在keep-alive給咱們提供了include這個api

Alt text

注意這個include存的是頁面的name,不是路由的name

所以,如何定義頁面的name是很關鍵的

個人作法是,vue頁面的name值與當前的menu.json的層級相連的name(實際上通過處理就是註冊路由的時候的全路徑name)對應,參考動態導入的介紹,這樣作用兩個目的:

  • 咱們知道vue的緩存組件keep-aliveinclude選項是基於頁面的name來緩存的,咱們使路由的name和頁面的name保持一致,這樣咱們一旦路由發生變化,咱們將全部路由的name存到store中,也就至關於存了頁面的name到了store中,這樣作緩存控制會很方便。固然頁面若是不須要緩存,能夠在menu.json中給這個菜單noCache設置爲true,這也是咱們菜單表結構中該字段的由來。
  • 咱們開發的時候通常都會安裝vue-devtools進行調試,語義化的name值方便進行調試。

例如角色管理

對應的json位置
Alt text

對應的vue文件
Alt text

對應的vue-devtools
Alt text

爲了更好的用戶體驗,咱們在系統裏面使用tag來記錄用戶以前點開的頁面的狀態。其實這也是一個hack手段,無非是解決SPA項目的一個痛點。

效果圖
Alt text

大概思路就是監聽路由變化,把全部路由的相關信息存到store中。根據該路由的noCache字段顯示不一樣的小圖標,告訴用戶這個路由是不是帶有緩存的路由。

組件的封裝或者基於UI庫的二次封裝

組件的封裝原則無非就是複用,可擴展。

咱們在最初封裝組件的時候不用追求過於完美,知足基礎的業務場景便可。後續根據需求變化再去慢慢完善組件。

若是是多人團隊的大型項目仍是建議使用Jest作好單元測試配合storybook生成組件文檔。

關於組件的封裝技巧,網上有不少詳細的教程,本人經驗有限,這裏就再也不討論。

使用plop建立模板

基本框架搭建完畢,組件也封裝好了以後,剩下的就是碼業務功能了。
對於中後臺管理系統,業務部分大部分離不開CRUD,咱們看到上面的截圖,相似用戶,角色等菜單,組成部分都大同小異,前端部分只要封裝好組件(列表,表單,彈框等),頁面均可以直接經過模板來生成。甚至如今有不少可視化配置工具(低代碼),我我的以爲目前不太適合專業前端,由於不少場景下頁面的組件都是基於業務封裝的,單純的把UI庫原生組件搬過來沒有意義。固然時間充足的話,能夠本身在項目上用node開發低代碼的工具。

這裏咱們能夠配合inquirer-directory來在控制檯選擇目錄

  • 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)
    }

通常狀況下, 咱們和後臺定義好restful規範的接口以後,每當有新的業務頁面的時候,咱們要作兩件事情,一個是寫好接口配置,一個是寫頁面,這兩個咱們能夠經過模板來建立了。咱們使用hbs來建立。

  • 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')
    
    // 斜槓轉駝峯
    function toCamel(str) {
      return str.replace(/(.*)\/(\w)(.*)/g, function (_, $1, $2, $3) {
          return $1 + $2.toUpperCase() + $3
      })
    }
    // 選項框
    const choices = ['create', 'update', 'get', 'delete', 'check', 'fetchList', 'fetchPage'].map((type) => ({
      name: type,
      value: type,
      checked: true
    }))
    
    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
      }
    }

咱們來執行plop

經過inquirer-directory,咱們能夠很方便的選擇系統目錄
Alt text
輸入name名,通常對應後端的controller名稱
Alt text
使用空格來選擇每一項,使用回車來確認
Alt text
最終生成的文件
Alt text

生成頁面的方式與此相似,我這邊也只是拋磚引玉,相信你們能把它玩出花來
相關文章
相關標籤/搜索