vite+Vue3+ts搭建通用后台管理系统

发表于 2年以前  | 总阅读数:748 次

通用后台管理系统整体架构方案(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。

image.png

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
├───generic
├───theme
├───tools
└───transition 
  • BEM 即Block, Element, Modifier,是OOCSS(面向对象css)的进阶版, 它是一种基于组件的web开发方法。blcok可以理解成独立的块,在页面中该块的移动并不会影响到内部样式(和组件的概念类似,独立的一块),element就是块下面的元素,和块有着藕断丝连的关系,modifier是表示样式大小等。 我们来看一下element-ui的做法

image.png

image.png

我们项目组件的开发或者封装统一使用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

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": []
            }
        ]
    }
] 

生成的菜单树

如果觉得所有页面的路由写在一个页面中太长,难以维护的话,可以把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

: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

image.png

注意这个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名称

使用空格来选择每一项,使用回车来确认

最终生成的文件

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

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/-5ywGLTh8ncXfu_SC73OeQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:6月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:6月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:6月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:6月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:6月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:6月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:6月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:6月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:6月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:6月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:6月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:6月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:6月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:6月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:6月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:6月以前  |  398次阅读  |  详细内容 »
 目录