Guide

element-admin-web 顾名思义,是 基于 Element 的 Web 管理后台。其核心来自于 vue-element-admin,我们再此基础上进行了封装,先下载模板体验看看吧,vue-element-admin

在模板项目的 src/components/Base 下,就是我们封装的基础组件,之所以没有做成 npm install 的包,就是为了让大家可以自由改造

此外,烟台城发是第一个使用这个框架的项目,虽然还不是很成熟,但可以参考烟台城发open in new window

版本计划

版本更新时间备注
1.0.02022 年 11 月 22 日初始化版本,与 我公司的 java 框架相结合
1.0.12023 年 1 月 3 日补充文档,BaseTable 增加了 min-width 属性,处理表格样式问题
1.0.22023 年 1 月 11 日修改了 vue.config.js 的 webpack 打包方案,在执行联想项目时,客户在自己的域名做了 CDN,导致部署业务不能及时显示,针对该问题修改 webpack,打包生成的文件带着时间戳,这样部署业务就不受 CDN 的影响了
1.0.32023 年 3 月 24 日BaseTable 组件增加了 page-sizes 属性,也就是可以设定分页的页数

下一步计划

首先,vue-element-admin 有一些缺点,甚至是硬伤,包括:

  • 版本太老,好久没有更新
  • 还是 vue2,不支持 vue3
  • 一些基础语法并不友好,例如可选链等
  • 样式一般
  • ...

虽然有这么多缺点,可能是历史原因,目前我们小伙伴还是更倾向于使用 vue-element-admin

现在我们团队封装了 Element-admin-web, Element-admin-web 更可能是一个过渡级别的方案,解决当前管理后台项目质量低的问题,还没有达到我们团队对管理后台项目的预期,但是在推广 Element-admin-web 的过程中,大家可以互相学习,升级组件,加强协作,是团队提升的必经之路。

我们的方向

宋岳明推荐了 Ant Design Pro 框架open in new window,客观来讲,这个框架挺好,而且它提出的不仅仅是框架,更是是最佳实践,我们应该学习和研究一下,但是:

  • 该框架用 react,当前团队还是以 vue 为主,当然这也不是我们拒绝这个框架的理由,我们的人员和技术也需要进化
  • 使用 ts,稍微有点重,当然该框架也提出了后端集成的 Api 方案,但还是重
  • 建议大家看看作者的 blog,他对工程化有理解、有认知,这样看来大家殊途同归
  • ...

开始使用

首先,做一个高质量的管理后台并不容易,我接手过很多的管理后台,痛点是:

  1. 框架不统一,导致依赖的 node 版本不一致,编译、部署困难
  2. 组件不统一,重复造轮子(有些轮子质量低到发指,漏洞百出),没有提升
  3. 几乎没有规范,data 中数据的命名、方法的命名、接口的定义,随心所欲,前后不搭
  4. 各种 bug,无穷尽
  5. ...

以富媒体组件为例,有 tinymce、wangEditor、UEditor 等等,但是想做一个好的富媒体容易么?

  1. 富媒体应该集成基本的 plugin,例如 powerPaste,确保从 web copy 的内容可以正常贴入
  2. 集成七牛,图片应该上传到七牛上,富媒体中只存储七牛链接,而不是图片 base64 编码。富媒体加入图片的方式有很多中,例如点选图片上传、拖拽图片上传等
  3. 视频也要上传到七牛并正确展示
  4. 上传七牛的文件名是否正确(不要用原始文件名,会在云端覆盖),应该图片名+随机字符串

下面是一个管理后台的列表页面常见的问题,你有思路来解决这些问题么?

管理后台常见问题

框架的愿景

我们希望开发一个 web 管理后台的基本页面,将基本的登陆、鉴权、CRUD 都集成起来,让我们的项目实施效率高一些、体验好一些、缺陷少一些。Element-admin-web 就是解决这个问题的,未来管理后台的实施职责划分到后端人员,即:

  1. 80%的单表 crud 由后台人员自行解决
  2. 个别复杂的编辑页面、详情页面由前端解决,要做好组件化管理

版本信息

项目的版本依赖:

  • node:v14.17.0
  • npm:6.14.14

基础配置

我相信大家一定会配置,但是这里提出的意思是大家要关注一些基础点,几乎没有项目会替换默认的 ico,上网找在线制作 ico 的工具,把 logo 做成 ico

基础配置

  • 应用名称:env 下的 VUE_APP_NAME
  • logo:/src/assets/images/logo.jpg
  • favicon:/public/favicon.ico

webpack 打包配置

在 vue.config.js 中,生成文件时增加时间戳,确保 dist 目录中生成的 js 文件不同

所有的前端项目我们都会增加这个配置,以确保我们的页面不会受到 CDN 缓存的影响

const TimeStamp = new Date().getTime();

...

configureWebpack: {
    // provide the app's title in webpack's name field, so that
    // it can be accessed in index.html to inject the correct title.
    name: name,
    output: {
      filename: `js/[name].${TimeStamp}.js`,
      chunkFilename: `js/[name].${TimeStamp}.js`
    },
    resolve: {
      alias: {
        '@': resolve('src')
      }
    }
  },

公共样式

针对于 margin、padding、字体大小、fx 布局等,在 styles 下面引入了 common.scss 和 global.scss

在 App.vue 中,已经引入这两个样式,针对自己的项目可以调整,例如你的项目有主色定义等,应该在 global.scss 中定义

我们不希望在各个页面零散的来定义样式,尽量都用 common.scss 和 global.scss 定义好的样式,例如我希望使用 flex 布局,则在 global.scss 中有定义 fx、fx-between、fx-center、fx-end、fx-start 等

<style src="./styles/common.scss" lang="scss"></style>
<style src="./styles/global.scss" lang="scss"></style>

配置文件说明

配置文件的事情,还是要好好说一下,基本原则:一些配置级的信息都要放在 env 中,例如调试 IM 时候,key、secret 等,原则上不允许通过注释的方式来控制变量,那么我们项目下有 3 个配置文件:

  • .env.development:本地开发环境
  • .env.staging:测试环境,对于前端来说,和 development 一样,但是在测试环境 jenkins 编译时,使用的是这里的配置文件
  • .env.production:生产环境

基本配置项目

配置名描述备注
ENV不知道干嘛的...vue-element-admin 中带着
VUE_APP_NAME应用名称
VUE_APP_BASE_API接口 URL请注意,只要到域名级别,不要后面加/api,未来可能扩展接口
VUE_APP_QINIU_URL七牛空间域名找项目经理协调
VUE_APP_QINIU_BUCKET七牛空间名称请注意,公共的 twst 已经回收,项目空间不允许混用

项目结构和命名

方法、字段和业务的命名很重要,我们希望一个业务实现的时候,就像一个人实施的一样,那么就需要大家遵循一定的规则。

api 接口的命名

一般情况下,我们后端输出的接口是规范的详见后端规范,例如都会有:

  • admin/create:新建管理员
  • admin/edit:编辑管理员
  • admin/query:分页查询管理员
  • admin/list:不分页查询管理员
  • ...

要求,接口都放在/api 下,每套对象一个接口,即/demander/create/demander/edit等,都应该做一个 demander.js 放在 api 下

接口命名

之所以这么设计,是因为有一些项目是按照页面管理接口的,那么接口管理可能就会重复,未来管理接口地址的时候,无从下手

所有接口的封装,以对象为标准,即同一对象放在一个 js 中,上述样例中,就是 appVersion 所有相关的接口(即接口中 appVersion/**的接口),都放在 demander.js 中

页面的放置和命名

一些前端有一个习惯,即按照 router 的层次管理来定义页面目录,我们不推荐这样处理。我们推荐的是一套业务一个目录。

之所以有这个原则是因为 router 的目录结构是业务,会变化,我们把对象管理起来,router 来组织目录

页面命名

命名规则

先讲下顶层的逻辑,当前,在实施中,一个项目最核心的是数据模型,也就是数据的设计,一切一切应该以数据库的命名为基础。

例如一个数据库中的管理员表叫做 sys_admin,里面有用户名 username、密码 password 两个字段,那么

  • 接口定义

后端整体的定义是 AdminController、AdminManager、AdminEntity 等,这里已经用框架控制,不赘述。其中,接口定义一定是/admin/create、/admin/edit、/admin/delete,其中 create 传入的参数一定是

{
	username:'用户名',
	password:'密码'
}
  • 前端开发页面时

管理员的页面一定叫做 adminXXX,例如 adminIndex.vue、adminPage.vue 等,而不是上百度搜索管理员的英文名

  • 字段的定义

我们一般的建议是后端先行,即后端人员先输出接口,然后前端再对接,前端的字段要根据接口参数和报文来

很多情况下,我们看到前端把字段定义的很杂乱,原则上 data 中要保存对象值,对象值就是接口返回的数据,例如下面 panelDataObj,就是/dashboard/panel返回的数据结构

data() {
    return {
      panelDataObj: {
        biddingOrderNumInFinished: 0,
        biddingOrderNumInProcess: 0,
        demanderNum: 0,
        purchaseOrderAmount: 0,
        purchaseOrderNum: 0,
        supplierNum: 0
      }
    }
  },

组件的封装和使用

首先,大家不要教条,Element-Admin-Web 的组件只适合于简单的表单业务,复杂的页面要手写。

本次封装了一些组件,包括

配置名描述备注
BaseCard后台卡片样式,没有实际意义
BaseForm基础表单,核心组件
BaseTable基础表格,核心组件
BaseTinymce富媒体封装带七牛、带插件
DialogForm封装了 BaseForm 的 Dialog
DrawerForm封装了 BaseForm 的 Drawer
IconButton基础图标按钮
SearchPanel搜索 Panel
TablePanel表格 Panel

组件可能存在问题,统一处理人为 TerryQi,如果你发现组件有提升点或者缺陷,请告知 TerryQi,TerryQi 认可后可以增加 1 小时加班工时

您可以直接下载模板项目,或者把模板项目中的/components/Base copy 到你的项目中,目前在每个组件下有 README.md 文件,稍后我会整理到 TeamBlog 中

SearchPanel 的使用

SearchPanel 即管理后台中的 Search 部分,具体的体验如下:

SearchPanel的体验

SearchPanel 的设计思路是根据 searchItems 来构建搜索页面,然后昱 searchForm 的变量结合在一起。在 config.js 中配置 searchItems,然后使用 SearchPanel

searchItems

searchItems 中主要定义了各个搜索条件的元素,主要属性有:

配置名是否必填描述备注
id必填id与 searchForm 中的 key 双向绑定使用
label必填搜索项目的标题
tooltip非必填可以对选项进一步解释悬浮提示
type必填选项类型有 inputText、dataPicker、monthPicker、select、datePickerDateRange
span必填栅格占用数即 24 分栅格占用几个
  • type 是最关键的字段,具体就是封装了一下 element 的组件,可以在你自己的项目中丰富 type

下面是一个 searchItems 的具体 json 样例:

export const searchItems = [
  {
    id: "name_Like",
    label: "快速检索",
    type: "inputText",
    placeholder: "请输入商品名称,模糊匹配",
    span: 8,
  },
  {
    id: "productGroupId",
    label: "商品分类",
    type: "select",
    placeholder: "请选择商品分类",
    options: [],
    span: 6,
  },
];

!!! 需要关注的点,对于 select 来说,options 的设置有一定技巧,如果设置后 select 的 options 没有展示,可能是姿势不对,需要用this.$set方法,把数据做成响应式

initOptions() {
      demanderList({}).then(res => {
        const demanderOptions = res.result
        demanderOptions.forEach((e) => {
          e.id = e.demanderId
          e.label = e.name
          e.value = e.demanderId
        })
        const demanderItemIndex = this.searchItems.findIndex((e) => {
          return e.id === 'demanderId'
        })

        this.$set(this.searchItems[demanderItemIndex], 'options', demanderOptions)
      })
    },

SearchPanel

使用 SearchPanel,绑定一下具体的值即可,然后完成 handleSearch 和 handleRest 方法

配置名是否必填描述备注
search-from必填搜索值有时在触发接口前也要整理一下,例如将 dataRange 数组类型拆分一下
search-items必填搜索栏目目
handleSearch必填搜索事件
handleClear必填重置事件
<search-panel ref="searchPanel" :search-form.sync="searchForm" :search-items="searchItems" @handleSearch="clickSearch" @handleClear="clickReset" />
    clickSearch() {
      this.searchForm.page = 1
      this.queryList()
    },
    clickReset() {
      this.searchForm = {
        searchWord: '',
        page: 1,
        size: 20
      }
      this.queryList()
    },

BaseCard 的使用

BaseCard 是个基本的样式,主要解决一些统一的页边距,背景色问题,一般列表中我们会把 SearchPanel 和 BaseTable 都放在 BaseCard 中

BaseCard的体验

BaseCard 主要控制一下样式,没有其他特殊功能

TablePanel 的使用

TablePanel 主要定义了一下样式

TablePanel的体验

配置名是否必填描述默认
title非必填表格标题
slot 的 rightPanel非必填右侧的按钮插槽
<table-panel :title="'管理员列表'">
          <template #rightPanel>
            <div class="fx fx-end">
              <el-button size="small" type="primary" @click="clickCreate()">新建管理员</el-button>
            </div>
          </template>
        </table-panel>

BaseTable 的使用

本质就是对 Element 的 Table 进行了下封装

BaseTable的体验

table-columns

表格的列

配置名是否必填描述默认值
prop必填配置名字段属性名
label必填列名
align必填对齐方式
fixed非必填固定方式'left'
width非必填宽度不填写则占满
min-width非必填最小宽度一般最后一列写 min-width,确保不换行
type必填展示类型text、textEnum、img、tooltip

关于 type 的说明

  • text:一般的文字
  • textEnum:主要结合我们的 Java 框架,展示枚举型的.message 的值
  • img:展示图片
  • tooltip:一般展示长文本,给定 width 后,用...标识,然后鼠标悬浮有提示

/**
 * 表格列
 */
export const tableColumns = [
  {
    prop: 'realName',
    label: '姓名',
    fixed: 'left',
    type: 'text',
    align: 'left',
    width: 150
  },
  {
    prop: 'phoneNumber',
    label: '手机号',
    type: 'text',
    align: 'left',
    width: 250
  },
  ....

BaseTable

配置名是否必填描述默认
tableHeight非必填表格高度550
tableData必填表格数据[]
tableColumns必填表格字段[]
showSummary非必填显示表格合计行false
pageSize非必填分页大小20
total非必填总条数0
page非必填当前页数1
paginationFlag非必填分页标识false
selectionFlag非必填全选标识true
loading非必填加载标识false
<base-table :table-columns="tableColumns" :table-data="tableObj.tableData" :page="tableObj.page" :page-size="tableObj.size" :total="tableObj.total" class="m-t-20" @handleCurrentChange="changePage">
          <template v-slot:status="scope">
            <div v-if="scope.row.status==='1'"><el-tag size="mini" effect="dark">生效</el-tag></div>
            <div v-if="scope.row.status==='0'"><el-tag type="info" size="mini" effect="dark">失效</el-tag></div>
          </template>

          <template v-slot:optColumn="scope">
            <div>
              <icon-button icon="el-icon-edit-outline" content="编辑" @clickButton="clickEdit(scope.row)" />
              <icon-button icon="el-icon-key" content="重置密码" @clickButton="clickResetPassword(scope.row)" />
              <icon-button v-if="scope.row.status==='0'" icon="el-icon-circle-check" content="设置为生效" @clickButton="clickSetStatus(scope.row,'1')" />
              <icon-button v-if="scope.row.status==='1'" icon="el-icon-circle-close" content="设置为失效" @clickButton="clickSetStatus(scope.row,'0')" />
            </div>
          </template>
        </base-table>

BaseForm 中的文件上传

这套基础组件来自于阜新项目中晶晶和宋岳明的代码,在文件上传处存在问题,这里是个思路,做组件:

  1. props 要封装好,参数描述清楚
  2. 组件要通用,可以满足多个情况,之前的代码中,只能满足一个指定参数的文件上传,不是很通用
<el-upload
              v-if="item.type === 'uploadFile'"
              action="https://upload-z1.qiniup.com/"
              :data="qiniuObj"
              :show-file-list="true"
              :file-list="formObj[item.id]"
              :on-success="(response, file, fileList)=>{return handleUploadSuccess(response, file, fileList,formObj[item.id],item.id)}"
              :before-upload="(file)=>{return handleBeforeUpload(file,formObj[item.id],item.id)}"
              :on-remove="(file,fileList)=>{return handleMoveFile(file,fileList,formObj[item.id],item.id)}"
              :limit="item.fileNum"
            >
              <el-button
                size="mini"
                type="primary"
              >{{ item.placeholder }}</el-button>
              <div slot="tip" class="size-12 m-t-10" style="line-height: 1">
                {{ item.tooltip }}
              </div>
            </el-upload>

IconButton 的使用

IconButton 主要是配合 BaseTable 来用的,作为操作栏的按钮控制统一样式

BaseTable的体验

配置名是否必填描述默认
content必填提示文字
icon必填图标
colorName非必填颜色'#409EFF'
size非必填按钮大小16
<div>
              <icon-button icon="el-icon-edit-outline" content="编辑" @clickButton="clickEdit(scope.row)" />
              <icon-button icon="el-icon-key" content="重置密码" @clickButton="clickResetPassword(scope.row)" />
              <icon-button v-if="scope.row.status==='0'" icon="el-icon-circle-check" content="设置为生效" @clickButton="clickSetStatus(scope.row,'1')" />
              <icon-button v-if="scope.row.status==='1'" icon="el-icon-circle-close" content="设置为失效" @clickButton="clickSetStatus(scope.row,'0')" />
            </div>

BaseForm 的使用

BaseForm 是基础的表单项,只适合于基础的配置页面

BaseForm的体验

首先,不建议 BaseForm 封装的太重,它还是解决简单 CRUD 页面的问题,具体的属性如下:

formItems

formItems 定义了表单都有什么属性

配置名是否必填描述默认值
id必填表单的 key
label必填label 名
type必填字段类型inputText、inputPassword、inputNumber、inputTextarea、datePicker、monthPicker、select、radioGroup、uploadFile、uploadImg、inputRichText
span必填占用栅格数24 等分栅格
rules非必填校验规则[{ required: true, message: '请输入姓名', trigger: 'blur' }]

type 的定义

  • inputText:输入文字
  • inputPassword:输入密码
  • inputNumber:输入数字
  • inputTextare:输入 textarea
  • dataPicker:日期选择器
  • monthPicker:月份选择器
  • select:select
  • radioGroup:单选框
  • uploadFile:上传文件
  • uploadImg:上传图片
  • inputRichText:tinymce 的富媒体,依赖于 BaseTinymce 组件
/**
 * 表单列
 */
export const createFormItems = [
  {
    id: "realName",
    label: "姓名",
    type: "inputText",
    placeholder: "请输入姓名",
    span: 24,
    rules: [{ required: true, message: "请输入姓名", trigger: "blur" }],
  },
  {
    id: "phoneNumber",
    label: "手机号",
    type: "inputText",
    placeholder: "请输入手机号",
    span: 24,
    tooltip: "手机号不能重复",
    rules: [{ required: true, message: "请输入手机号", trigger: "blur" }],
  },
  {
    id: "password",
    label: "密码",
    type: "inputPassword",
    placeholder: "请输入密码",
    span: 24,
    tooltip: "要求密码在6至16位之间",
    rules: [{ required: true, message: "请输入密码", trigger: "blur" }],
  },
];

BaseForm

BaseForm 使用 DialogForm 和 DrawerForm 分别包装了一下

配置名是否必填描述默认
formItems必填表单字段
formObj必填表单值
labelPosition非必填label 位置'left'
labelWidth非必填label 宽度'120px'
<dialog-form v-if="showCreateDialog" ref="CreateDialogForm" :form-items="createFormItems" :form-obj="selectedObj" dialog-width="30%" title="管理员管理" @closeDialog="closeDialog" @clickSave="createForm" />

DialogForm 和 DrawerForm

本质上,DialogForm 和 DrawerForm 是相同的,区别在于适应的场景不同。

DialogForm

DialogForm 适用于表单较小的场景,本质是把 BaseForm 承载出来

DialogForm样式

配置名是否必填描述默认
title必填Dialog 标题
dialogWidth非必填Dialog 宽度'50%'
formItems必填表单字段
formObj必填表单值
labelPosition非必填label 位置'left'
labelWidth非必填label 宽度'120px'
<dialog-form v-if="showSaveDialog" :form-items="formItems" :form-obj="selectedObj" dialog-width="30%" title="版本管理" @closeDialog="closeDialog" @clickSave="saveForm" />

DrawerForm

DialgDrawer 适用于表单较长的场景

DrawerForm样式

配置名是否必填描述默认
showFlag非必填显示标识false
title必填Dialog 标题
drawerWidth非必填Dialog 宽度'50%'
formItems必填表单字段
formObj必填表单值
labelPosition非必填label 位置'left'
labelWidth非必填label 宽度'120px'
<drawer-form :show-flag.sync="showSaveDrawer" :form-items="formItems" label-position="top" :form-obj="selectedObj" drawer-width="60%" title="商品管理" @closeDrawer="closeDrawer" @clickSave="saveForm" />

BaseTinymce 的使用

BaseTinymce 依赖于七牛,无其他问题,我们都是用这个富媒体组件

目前图片、视频都上传到七牛再展示,这个版本的 BaseTinymce 依赖于 env 中的 VUE_APP_QINIU_BUCKET、VUE_APP_QINIU_URL 和获取七牛 Token 接口,如果对接其他后端,需要改造

  mounted() {
    tinymce.init({})
    this.htmlValue = this.value
    const qiniuBucket = process.env.VUE_APP_QINIU_BUCKET
    qiniuToken({ bucketName: qiniuBucket, dir: 'tinymce' }).then((res) => {
      this.qiniuObj = res.result
    })
  },

    getOssFileUrl(fileKey) {
      return process.env.VUE_APP_QINIU_URL + fileKey
    },
Last Updated:
Contributors: TerryQi