关于vite:vitevue3ts搭建通用后台管理系统

6次阅读

共计 18977 个字符,预计需要花费 48 分钟才能阅读完成。

通用后盾管理系统整体架构计划(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! 的中央

以上是给的范式,咱们不肯定要齐全依照它的形式,能够联合 BEMACSS

目前我给出的 CSS 文件目录(暂定)
└─styles

├───acss
├───base
├───settings
├───theme
└───tools


  • 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 机制,这里波及到的变动比拟多,临时先不提及

应用时,咱们分 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

因为生产环境不会间接引入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

:3.5.0API 也更新到了 addRoute,留神辨别版本变动

vue-router4x

集体更偏向于应用 vue-router4xaddRoute办法,这样能够更精密的管制每一个路由的的定位

大体思路为,在 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 中,权限与角色相关联,用户通过成为适当角色的成员而失去这些角色的权限。这就极大地简化了权限的治理。这样治理都是层级相互依赖的,权限赋予给角色,而把角色又赋予用户,这样的权限设计很分明,治理起来很不便。

这样登录的时候只有获取用户

用户抉择角色

角色绑定菜单

菜单

页面缓存管制

页面缓存,听起来无关紧要的性能,却能给客户带来极大的应用体验的晋升。
例如咱们有一个分页列表,输出某个查问条件之后筛选出某一条数据,点开详情之后跳转到新的页面,敞开详情返回分页列表的页面,如果之前查问的状态不存在,用户须要反复输出查问条件,这不仅耗费用户的急躁,也减少了服务器不必要的压力。

因而,缓存管制在零碎外面很有存在的价值,咱们晓得 vuekeep-alive组件能够让咱们很不便的进行缓存,那么是不是咱们间接把根组件间接用 keep-alive 包装起来就好了呢?

实际上这样做是不适合的,比方我有个用户列表,关上小明和小红的详情页都给他缓存起来,因为缓存是写入内存的,用户应用零碎久了之后必将导致系统越来越卡。并且相似于详情页这种数据应该是每次关上的时候都从接口获取一次能力保障是最新的数据,将它也缓存起来自身就是不适合的。那么按需缓存就是咱们零碎迫切需要应用的,好在 keep-alive 给咱们提供了 include 这个 api

留神这个 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 地位

对应的 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 名称

应用空格来抉择每一项,应用回车来确认

最终生成的文件

生成页面的形式与此相似,我这边也只是抛砖引玉,置信大家能把它玩出花来

正文完
 0