vite-vue3-ts 搭建通用後臺管理系統
通用後臺管理系統整體架構方案 (Vue)
項目創建,腳手架的選擇 (vite or vue-cli)
-
vue-cli基於webpack封裝,生態非常強大,可配置性也非常高,幾乎能夠滿足前端工程化的所有要求。缺點就是配置複雜,甚至有公司有專門的webpack工程師專門做配置,另外就是 webpack 由於開發環境需要打包編譯,開發體驗實際上不如vite。 -
vite開發模式基於esbuild,打包使用的是rollup。急速的冷啓動和無縫的hmr在開發模式下獲得極大的體驗提升。缺點就是該腳手架剛起步,生態上還不及webpack。
本文主要講解使用vite來作爲腳手架開發。(動手能力強的小夥伴完全可以使用vite做開發服務器,使用webpack做打包編譯放到生產環境)
爲什麼選擇 vite 而不是 vue-cli,不論是webpack,parcel,rollup等工具,雖然都極大的提高了前端的開發體驗,但是都有一個問題,就是當項目越來越大的時候,需要處理的js代碼也呈指數級增長,打包過程通常需要很長時間 (甚至是幾分鐘!) 才能啓動開發服務器,體驗會隨着項目越來越大而變得越來越差。
由於現代瀏覽器都已經原生支持 es 模塊,我們只要使用支持 esm 的瀏覽器開發,那麼是不是我們的代碼就不需要打包了?是的,原理就是這麼簡單。vite 將源碼模塊的請求會根據304 Not Modified進行協商緩存,依賴模塊通過Cache-Control:max-age=31536000,immutable進行協商緩存,因此一旦被緩存它們將不需要再次請求。
軟件巨頭微軟週三 (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
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 架構設計同樣需要重視起來。
ITCSS
ITCSS 是 CSS 設計方法論,它並不是具體的 CSS 約束,他可以讓你更好的管理、維護你的項目的 CSS。
ITCSS 把 CSS 分成了以下的幾層
| Layer | 作用 | | --- | --- | | Settings | 項目使用的全局變量 | | Tools | mixin,function | | Generic | 最基本的設定 normalize.css,reset | | Base | type selector | | Objects | 不經過裝飾 (Cosmetic-free) 的設計模式 | | Components | UI 組件 | | Trumps | helper 唯一可以使用 important! 的地方 |
以上是給的範式,我們不一定要完全按照它的方式,可以結合BEM和ACSS
目前我給出的 CSS 文件目錄 (暫定)
└─styles
├───acss
├───generic
├───theme
├───tools
└───transition
BEM
即 Block, Element, Modifier,是OOCSS(面向對象 css) 的進階版, 它是一種基於組件的 web 開發方法。blcok 可以理解成獨立的塊,在頁面中該塊的移動並不會影響到內部樣式(和組件的概念類似,獨立的一塊),element 就是塊下面的元素,和塊有着藕斷絲連的關係,modifier 是表示樣式大小等。
我們來看一下element-ui的做法
我們項目組件的開發或者封裝統一使用BEM
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
數據安全
-
不應該在 jwt 的 payload 部分存放敏感信息,因爲該部分是客戶端可解密的部分
-
保護好 secret 私鑰,該私鑰非常重要
-
如果可以,請使用 https 協議
使用流程
使用方式
-
後端
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": []
}
]
}
]
生成的菜單樹
如果覺得所有頁面的路由寫在一個頁面中太長,難以維護的話,可以把
json換成 js 用import機制,這裏涉及到的變動比較多,暫時先不提及
使用時,我們分development和production兩種環境
-
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}`))
})
})
注意上面是通過使用
md5對name進行加密生成主鍵id到數據庫中
我們嘗試用 node 執行該 js
node createMenu.js
由於生產環境不會直接引入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-clivue-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 }, []) } -
vitevite2 之後可以直接使用 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-router的addRoute和導航守衛beforeEach兩個方法
要實現動態添加路由,即只有有權限的路由纔會註冊到 Vue 實例中。考慮到每次刷新頁面的時候由於 vue 的實例會丟失,並且角色的菜單也可能會更新,因此在每次加載頁面的時候做菜單的拉取和路由的注入是最合適的時機。因此核心是 vue-router 的addRoute和導航鉤子beforeEach兩個方法
vue-router3x
注:3.5.0API 也更新到了 addRoute,注意區分版本變化
vue-router4x
個人更傾向於使用vue-router4x的addRoute方法,這樣可以更精細的控制每一個路由的的定位
大體思路爲,在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 中,權限與角色相關聯,用戶通過成爲適當角色的成員而得到這些角色的權限。這就極大地簡化了權限的管理。這樣管理都是層級相互依賴的,權限賦予給角色,而把角色又賦予用戶,這樣的權限設計很清楚,管理起來很方便。
這樣登錄的時候只要獲取用戶
用戶選擇角色
角色綁定菜單
菜單
頁面緩存控制
頁面緩存,聽起來無關緊要的功能,卻能給客戶帶來極大的使用體驗的提升。
例如我們有一個分頁列表,輸入某個查詢條件之後篩選出某一條數據,點開詳情之後跳轉到新的頁面,關閉詳情返回分頁列表的頁面,假如之前查詢的狀態不存在,用戶需要重複輸入查詢條件,這不僅消耗用戶的耐心,也增加了服務器不必要的壓力。
因此,緩存控制在系統裏面很有存在的價值,我們知道vue有keep-alive組件可以讓我們很方便的進行緩存,那麼是不是我們直接把根組件直接用keep-alive包裝起來就好了呢?
實際上這樣做是不合適的,比如我有個用戶列表,打開小明和小紅的詳情頁都給他緩存起來,由於緩存是寫入內存的,用戶使用系統久了之後必將導致系統越來越卡。並且類似於詳情頁這種數據應該是每次打開的時候都從接口獲取一次才能保證是最新的數據,將它也緩存起來本身就是不合適的。那麼按需緩存就是我們系統迫切需要使用的,好在keep-alive給我們提供了include這個 api
注意這個 include 存的是頁面的 name,不是路由的 name
因此,如何定義頁面的 name 是很關鍵的
我的做法是,vue 頁面的 name 值與當前的menu.json的層級相連的name(實際上經過處理就是註冊路由的時候的全路徑 name) 對應,參考動態導入的介紹,這樣做用兩個目的:
-
我們知道 vue 的緩存組件
keep-alive的include選項是基於頁面的name來緩存的,我們使路由的name和頁面的name保持一致,這樣我們一旦路由發生變化,我們將所有路由的name存到store中,也就相當於存了頁面的name到了store中,這樣做緩存控制會很方便。當然頁面如果不需要緩存,可以在menu.json中給這個菜單noCache設置爲true, 這也是我們菜單表結構中該字段的由來。 -
我們開發的時候一般都會安裝
vue-devtools進行調試,語義化的name值方便進行調試。
例如角色管理
對應的 json 位置
對應的 vue 文件
對應的vue-devtools
爲了更好的用戶體驗,我們在系統裏面使用 tag 來記錄用戶之前點開的頁面的狀態。其實這也是一個hack手段,無非是解決SPA項目的一個痛點。
效果圖
大概思路就是監聽路由變化,把所有路由的相關信息存到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,我們可以很方便的選擇系統目錄
輸入 name 名,一般對應後端的 controller 名稱
使用空格來選擇每一項,使用回車來確認
最終生成的文件
生成頁面的方式與此類似,我這邊也只是拋磚引玉,相信大家能把它玩出花來
項目地址
levi-vue-admin
關於本文
作者:sky124380729
https://segmentfault.com/a/1190000040096254
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/XryRXg9H-nVZl_KWs7bPew