通用后盾管理系统整体架构计划(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.jsmodule.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我的项目应用的全局变量
Toolsmixin,function
Generic最根本的设定 normalize.css,reset
Basetype selector
Objects不通过装璜 (Cosmetic-free) 的设计模式
ComponentsUI 组件
Trumpshelper 惟一能够应用 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状态码返回401app.use(async (ctx, next) => {  await next().catch(err => {      if(err.status === 401) {          ctx.body.msg = 'token认证失败'      }  })})

菜单设计

对于菜单的生成形式有很多种,比拟传统的是前端保护一个菜单树,依据后端返回的菜单树进行过滤。这种形式实际上提前将路由注册进入到实例中,这种当初其实曾经不是最佳实际了。

当初支流的思路是后端通过XML来配置菜单,通过配置来生成菜单。前端登录的时候拉取该角色对应的菜单,通过addroute办法注册菜单相应的路由地址以及页面在前端我的项目中的门路等。这是比拟支流的,然而我集体感觉不算最完满。
咱们菜单和前端代码其实是强耦合的,包含路由地址,页面门路,图标,重定向等。我的项目初期菜单可能是常常变动的,每次对菜单进行增加或者批改等操作的时候,须要告诉后端批改XML,并且后端的XML实际上就是没有树结构,看起来也不是很不便。

因而我采纳如下设计模式,前端保护一份menu.json,所写即所得,json数是什么样在菜单配置的时候就是什么样。

结构设计
keytypedescription
titlestring菜单的题目
namestring对应路由的name,也是页面或者按钮的惟一标识,重要,看上面注意事项
typestringMODULE代表模块(子系统,例如APP和后盾管理系统),MENU代表菜单,BUTTON代表按钮
pathstring门路,对应路由的path
redirectstring重定向,对应路由的redirect
iconstring菜单或者按钮的图标
componentstring当作为才当的时候,对应菜单的我的项目加载地址
hiddenboolean当作为菜单的时候是否在左侧菜单树暗藏
noCacheboolean当作为菜单的时候该菜单是否缓存
fullscreenboolean当作为菜单的时候是否全屏显示以后菜单
childrenarray顾名思义,下一级
注意事项:同级的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 SQLconst 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指令来管制按钮的显示
示例代码

// 生成权限按钮表存到storeconst 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.tsexport 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.jsrouter.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.jsimport { createRouter, createWebHistory } from 'vue-router'import type { RouteRecordRaw } from './types'// static modulesimport 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}}// Createexport const create{{ properCase name }} = (data: any) => request.post('{{camelCase name}}/', data){{/if}}{{#if delete}}// Deleteexport const remove{{ properCase name }} = (id: string) => request.delete(`{{camelCase name}}/${id}`){{/if}}{{#if update}}// Updateexport const update{{ properCase name }} = (id: string, data: any) => request.put(`{{camelCase name}}/${id}`, data){{/if}}{{#if get}}// Retrieveexport const get{{ properCase name }} = (id: string) => request.get(`{{camelCase name}}/${id}`){{/if}}{{#if check}}// Check Uniqueexport const check{{ properCase name }} = (data: any) => request.post(`{{camelCase name}}/check`, data){{/if}}{{#if fetchList}}// List queryexport const fetch{{ properCase name }}List = (params: any) => request.get('{{camelCase name}}/list', { params }){{/if}}{{#if fetchPage}}// Page queryexport 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名称

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

最终生成的文件

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