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

生成页面的方式与此相似,我这边也只是抛砖引玉,相信你们能把它玩出花来
相关文章
相关标签/搜索