Guide
element-admin-web 顾名思义,是 基于 Element 的 Web 管理后台。其核心来自于 vue-element-admin,我们再此基础上进行了封装,先下载模板体验看看吧,vue-element-admin。
在模板项目的 src/components/Base 下,就是我们封装的基础组件,之所以没有做成 npm install 的包,就是为了让大家可以自由改造
此外,烟台城发是第一个使用这个框架的项目,虽然还不是很成熟,但可以参考烟台城发
版本计划
版本 | 更新时间 | 备注 |
---|---|---|
1.0.0 | 2022 年 11 月 22 日 | 初始化版本,与 我公司的 java 框架相结合 |
1.0.1 | 2023 年 1 月 3 日 | 补充文档,BaseTable 增加了 min-width 属性,处理表格样式问题 |
1.0.2 | 2023 年 1 月 11 日 | 修改了 vue.config.js 的 webpack 打包方案,在执行联想项目时,客户在自己的域名做了 CDN,导致部署业务不能及时显示,针对该问题修改 webpack,打包生成的文件带着时间戳,这样部署业务就不受 CDN 的影响了 |
1.0.3 | 2023 年 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 框架,客观来讲,这个框架挺好,而且它提出的不仅仅是框架,更是是最佳实践,我们应该学习和研究一下,但是:
- 该框架用 react,当前团队还是以 vue 为主,当然这也不是我们拒绝这个框架的理由,我们的人员和技术也需要进化
- 使用 ts,稍微有点重,当然该框架也提出了后端集成的 Api 方案,但还是重
- 建议大家看看作者的 blog,他对工程化有理解、有认知,这样看来大家殊途同归
- ...
开始使用
首先,做一个高质量的管理后台并不容易,我接手过很多的管理后台,痛点是:
- 框架不统一,导致依赖的 node 版本不一致,编译、部署困难
- 组件不统一,重复造轮子(有些轮子质量低到发指,漏洞百出),没有提升
- 几乎没有规范,data 中数据的命名、方法的命名、接口的定义,随心所欲,前后不搭
- 各种 bug,无穷尽
- ...
以富媒体组件为例,有 tinymce、wangEditor、UEditor 等等,但是想做一个好的富媒体容易么?
- 富媒体应该集成基本的 plugin,例如 powerPaste,确保从 web copy 的内容可以正常贴入
- 集成七牛,图片应该上传到七牛上,富媒体中只存储七牛链接,而不是图片 base64 编码。富媒体加入图片的方式有很多中,例如点选图片上传、拖拽图片上传等
- 视频也要上传到七牛并正确展示
- 上传七牛的文件名是否正确(不要用原始文件名,会在云端覆盖),应该图片名+随机字符串
下面是一个管理后台的列表页面常见的问题,你有思路来解决这些问题么?
框架的愿景
我们希望开发一个 web 管理后台的基本页面,将基本的登陆、鉴权、CRUD 都集成起来,让我们的项目实施效率高一些、体验好一些、缺陷少一些。Element-admin-web 就是解决这个问题的,未来管理后台的实施职责划分到后端人员,即:
- 80%的单表 crud 由后台人员自行解决
- 个别复杂的编辑页面、详情页面由前端解决,要做好组件化管理
版本信息
项目的版本依赖:
- 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 的设计思路是根据 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 主要控制一下样式,没有其他特殊功能
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 进行了下封装
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 中的文件上传
这套基础组件来自于阜新项目中晶晶和宋岳明的代码,在文件上传处存在问题,这里是个思路,做组件:
- props 要封装好,参数描述清楚
- 组件要通用,可以满足多个情况,之前的代码中,只能满足一个指定参数的文件上传,不是很通用
<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 来用的,作为操作栏的按钮控制统一样式
配置名 | 是否必填 | 描述 | 默认 |
---|---|---|---|
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 封装的太重,它还是解决简单 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 承载出来
配置名 | 是否必填 | 描述 | 默认 |
---|---|---|---|
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 适用于表单较长的场景
配置名 | 是否必填 | 描述 | 默认 |
---|---|---|---|
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
},