今年3月到7月,我参与了课题组负责的某机床厂远程运维平台开发的项目,并主要承担前端开发工作。这个项目马上(10月底)就要结题了,因此总结一下在这半年的开发过程中遇到的一些技术问题或难题,以给之后的学习或开发作参考。
1 项目简介
这个项目是导师接的一个工业互联网项目,用于某机床厂的远程运营维护。从3月开始,我们就着手参与这个项目。
本平台主要适用于工业互联网远程运维场景,采集机床运行数据,及时、准确地为机床厂提供机床的各项运行数据可视化、设备故障的报警等相关信息,高效地为用户提供机床的远程运维服务。
——《智能运维云平台用户操作手册》
这个平台由3个团队共同参与开发,分别是杭州团队(浙大)、苏州团队(苏州研究院)和昆山团队(昆山某公司),后来昆山团队退出了,项目就完全由杭州和苏州负责了。
这个项目里我们杭州团队负责了【企业中心】、【设备中心】的开发,在昆山团队退出后,我们还额外负责了原来昆山做的【工作台】、【统计报表】部分。杭州团队是包括我在内的3个研究生,我负责前端,另外2个同学负责后端,其中1个还负责数据库管理。
4月初,我们经过了多次讨论,研究了若干技术选型,如人人开源、JeecgBoot等,最终决定以若依管理系统为基础,搭建我们的平台。从以下我们做好的首页中,也可以见到一丝端倪。
(首页除了下面的合作伙伴是我设计的外,其他全部是苏州团队设计的)
从4月开始,我着手学习前端三件套、Vue与ElementUI,并到后期边做边学,积累了很多技术经验。并且由于前端需要后端支持,因此我也在自己的电脑上搭配了完整的后端环境。
2 企业中心
【企业中心】用于管理该公司对接的所有企业,也就是它的销售方。
2.1 企业管理
我们第一个着手做的就是【企业中心】的【企业管理】模块。这是一个单表,即这个模块只需要增删改查的功能,能实现数据项的罗列与变更功能。由于若依系统自带代码生成器,可以根据数据库的某个表自动生成后端代码(domain、mapper、service、controller四个层)以及前端代码(api接口和vue页面),所以这很大程度减轻了我们的工作量,我们只需要生成表再改一改就好了。下文中与表有关的模块大多数都是这么做的。
2.1.1 企业地区
这是后来加的一个功能,客户要求在【企业管理】模块,每个企业可以有一个对应的地区,并且可以根据地区筛选企业。
要导入全国的省市区数据就是一个很麻烦的过程,这个是很难做到写死在代码里的。我找到了ElementUI提供的一个npm包,包含所有中国省市区级联数据,即element-china-area-data,并根据这个页面的示例做了这个功能。这个包是ElementUI提供的,所以可以直接使用ElementUI的级联选择器。
导入这个包里所用到的几个数组和对象。
1
import {provinceAndCityData, regionData, provinceAndCityDataPlus, regionDataPlus, CodeToText, TextToCode} from 'element-china-area-data';
数据库里,每一条企业数据省、市、区是三个字段,在前端表格的显示上,要稍微修改一下格式:
1
2
3
4
5<el-table v-loading="loading" :data="infoList" @selection-change="handleSelectionChange">
...
<el-table-column label="地区" align="center" prop="province" :formatter="provinceFormat"/>
...
</el-table>1
2
3
4
5
6
7
8
9
10
11
12
13// 地区处理
provinceFormat(row, column) {
let region = '';
if (row.province) {
region += row.province;
}
for (let item of [row.city, row.country]) {
if (item) {
region += '/' + item;
}
}
return region;
},因为数据库里有单独的“省”字段,因此搜索时可以只筛选省。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19<el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="75px">
<el-form-item label="企业地区" prop="province">
<el-select
size="small"
v-model="queryParams.province"
clearable
filterable
placeholder="请选择或搜索企业地区"
>
<el-option
v-for="province in provinceOptions"
:key="province.value"
:label="province.label"
:value="province.value">
</el-option>
</el-select>
</el-form-item>
...
</el-form>1
2
3
4
5
6
7data() {
return {
...
provinceOptions: regionData.map(province => ({value: province.label, label: province.label})),
...
}
}新增企业功能,客户要求省份信息必填,市区信息选填,所以这个级联菜单与element-china-area-data的展示页面做得有所不同。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49<el-dialog :title="title" :visible.sync="open" width="700px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
...
<el-col :span="12">
<el-form-item label="企业地区" prop="province">
<el-select
v-model="form.province"
display="inline-block"
placeholder="请选择或搜索企业地区"
clearable
filterable
style="width: 100%;"
@change="handleRegionInfo">
<el-option
v-for="province in provinceOptions"
:key="province.value"
:label="province.label"
:value="province.value">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="具体市区" prop="selectedRegionOptions">
<el-cascader
v-if="form.province"
:options="regionOptions"
v-model="form.selectedRegionOptions"
:key="provinceRefresh"
clearable
filterable
style="width: 100%"
placeholder="请选择或搜索具体市区"
>
</el-cascader>
<el-cascader
v-else
disabled
style="width: 100%"
placeholder="请先选择企业地区">
</el-cascader>
</el-form-item>
</el-col>
...
</el-row>
</el-form>
...
</el-dialog>下拉列表和级联列表要联动,什么省就要给什么市,所以要给级联表加个
key
(否则级联表不会根据所选省份变化),并且绑定到一个provinceRefresh
的变量上,并在其他相关部分将其设置为省份的code
,然后在某个函数处理省份的变化,见如下的script
部分:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79data() {
return {
...
regionOptions: [],
provinceRefresh: null,
rules: {
...
province: [
{required: true, message: "企业地区不能为空", trigger: "change"}
],
...
},
};
},
methods: {
...
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.regionOptions = this.form.province ? regionData.find(province => province.label === this.form.province).children : [];
this.provinceRefresh = this.form.province ? TextToCode[this.form.province].code : null;
this.open = true;
this.title = "添加企业";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const id = row.id || this.ids
getInfo(id).then(response => {
this.form = response.data;
if (this.form.province) {
this.regionOptions = regionData.find(province => province.label === this.form.province).children;
if (this.form.city && this.form.country) {
this.form.selectedRegionOptions = [TextToCode[this.form.province][this.form.city].code, TextToCode[this.form.province][this.form.city][this.form.country].code];
}
this.provinceRefresh = TextToCode[this.form.province].code;
}
this.open = true;
this.title = "修改企业";
});
},
/** 提交按钮 */
submitForm() {
if (this.form.selectedRegionOptions.length) {
this.form.city = CodeToText[this.form.selectedRegionOptions[0]];
this.form.country = CodeToText[this.form.selectedRegionOptions[1]];
}
else {
this.form.city = '';
this.form.country = '';
}
this.provinceRefresh = null;
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateInfo(this.form).then(response => {
this.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addInfo(this.form).then(response => {
this.msgSuccess("新增成功");
this.open = false;
this.getList();
});
}
}
});
},
...
// 企业地区修改
handleRegionInfo() {
if (this.form.province) {
this.regionOptions = regionData.find(province => province.label === this.form.province).children;
this.provinceRefresh = TextToCode[this.form.province].code
}
}
}
2.1.2 字典的使用
字典是若依系统自带的功能,对于一些常用的选项,若依可以将他们存在同一个数据库中,并设置键、值,以便可以随时调用。
通常使用字典的步骤如下:
在【字典管理】里新增一个字典,设置字典名称(即字典释义)和字典类型(即变量名):
点开这个字典页面,向里面添加数据,分别输入标签和键值:
在前端的Vue代码里,使用下拉菜单时,绑定键值:
1
2
3
4
5
6
7
8
9
10<el-form-item label="数据类型" prop="paramType">
<el-select v-model="queryParams.paramType" placeholder="请选择数据类型" clearable size="small">
<el-option
v-for="dict in paramTypeOptions"
:key="dict.dictValue"
:label="dict.dictLabel"
:value="dict.dictValue"
/>
</el-select>
</el-form-item>在前端的
script
部分,定义这个数组,并且使用若依自带的函数去获取它:1
2
3
4
5
6
7
8
9
10
11
12
13
14data() {
return {
...
paramTypeOptions: [],
...
};
},
created: {
...
this.getDicts("device_param_type").then(response => {
this.paramTypeOptions = response.data;
});
...
},在表格里使用时,需要定义一个格式函数,因为数据库里存的是字典键值,表格里展示的是字典标签:
1
<el-table-column label="数据类型" align="center" prop="paramType" :formatter="paramTypeFormat"/>
1
2
3
4// 数据类型字典翻译
paramTypeFormat(row, column) {
return this.selectDictLabel(this.paramTypeOptions, row.paramType);
},
在【企业管理】模块里,用到了许多类似于字典的选项,如经营状态、企业行业、公司类型,但我在代码中没用使用字典,而是写死在代码的数组里的。这样做的好处是节省函数调用,坏处是当我需要调整选项的时候,无法从前端或数据库去更改它,而是需要修改源代码。
1 | <el-form-item label="经营状态" prop="operationState"> |
1 | data() { |
当然可能有更好的写法,但是只要让这个数组成为一个对象数组,并且有键值对(即value
和label
)就好了。
如果需要前面出现编号,就改一下label
:
1 | getCompanyTypeOptions() { |
(对于JavaScript的for-each
循环,in
循环的是数组的索引或对象的键,即let index in array
、let key in object
,of
循环的是数组的元素,即let item of array
;知道了索引或键,可以通过方括号或小数点去访问对应的元素或值;更详细可以看这篇文章:《JavaScript中遍历数组、对象的几种方式》)
2.1.3 删除按钮的提示修改
若依自带的删除提示是这样的:
1 | /** 删除按钮操作 */ |
第3行这个获取id,实际上是判断删除按钮的入口,是单个数据项,还是勾选后点击的删除,前者是一个对象(即参数row
)的id,后者是一个id数组,通过逻辑或选择一个值。但是第4行会暴露主键id,因此我对这部分代码(以及我们做的所有模块的删除功能)做了如下修改:
1 | /** 删除按钮操作 */ |
JavaScript的typeof
函数可以返回变量的类型(字符串表示),如果是number
类型,说明这是row.id
,长度为1;如果是object
类型(数组即对象),说明这是this.ids
,长度为数组的length
。再判断ids的长度是否大于1,输出对应的结果。
2.1.4 解决手机端消息框显示不全的问题
在做响应式布局时遇到的bug,像上面删除时弹出的警告框,如果屏幕宽度过窄,可能显示不全,连确定按钮都点不到:
所以我在代码的最后都加了一个样式:
1 | <style> |
使消息框在屏幕尺寸小于750px时,宽度调整为相对尺寸(80%),避免显示不全。
2.2 组织管理
【组织管理】模块是一个树表。客户的要求是,对于某一个企业,可以给它加下级组织(下级部门),这个组织可以继续添加下级组织,企业也是一个组织,所有的企业应平级,即企业的上级组织汇聚到一个顶级组织(顶点)。加了组织后,机床就可以绑定到这个组织。
2.2.1 树表的逻辑
数据库是没法放树表的,只可以放单表。每个组织在数据库中是单独的一项,表面上是同级的,但是可以给它加一个字段,表示该组织的上级组织。由于上下级组织是一对多的关系,所以我们只关注某个组织的上级组织,并一层层关联起来。
数据库里,org_id
是主键,enterprise_id
跟踪这个组织的最顶层(指企业层)是什么,higher_org
跟踪这个组织的上层组织是什么。
前端页面的显示方式见上上上图。
2.2.2 后端对于树表的处理
由于一个企业同时也是一个组织,所以凡是在【企业管理】模块做的增删改操作都要关联到【组织管理】模块:
- 增:【企业管理】增加一个企业,【组织管理】就要增加这个企业组织
- 删:【企业管理】删除一个企业,【组织管理】就要删除这个企业组织及其所有下级组织(所有
enterprise_id
符合企业id的组织) - 改:【企业管理】更改了企业的名称,【组织管理】就要对对应的企业组织(在所有
enterprise_id
符合企业id的组织里找higher_org
为0的组织)改名
这个工作前端后端都可以做,经过权衡,我们决定让后端完成这个工作,更改xml
文件就可以了。
2.2.3 前端对于树表的处理
若依展示树表的方式是使用一个Treeselect
组件。之后的代码里,如果要使用树表下拉框,也需要注册这个组件。
1 | import Treeselect from "@riophae/vue-treeselect"; |
树表在下拉框的使用:
1 | <el-col :span="24" v-if="form.higherOrg !== 0"> |
我对若依自动生成的树表进行了一些修改,以便更满足我们的逻辑要求:
若依默认的下拉树结构有顶级节点,并设置id为0:
1
2
3
4
5
6
7
8
9/** 查询部门下拉树结构 */
getTreeselect() {
listOrg().then(response => {
this.orgOptions = [];
const data = { orgId: 0, deptName: '顶级节点', children: [] };
data.children = this.handleTree(response.data, "orgId", "higherOrg");
this.orgOptions.push(data);
});
},我们需要有顶级节点的存在,但是不希望它出现在下拉树里,就把它去掉,直接设置
orgOptions
:1
2
3
4
5
6/** 查询部门下拉树结构 */
getTreeselect() {
listOrg().then(response => {
this.orgOptions = this.handleTree(response.data, "orgId", "higherOrg");
});
},用户点击表格上方的新增时,页面如下:
用户点击表格右侧的新增时,可以填充对应的所属企业与上级组织(表示新增该组织的下级组织),符合操作逻辑:
1
2
3
4
5
6
7
8
9
10
11/** 新增按钮操作 */
handleAdd(row) {
this.reset();
if (row !== undefined) {
this.form.higherOrg = row.orgId;
this.form.enterpriseId = row.enterpriseId;
}
this.getTreeselect();
this.open = true;
this.title = "添加组织";
},对于企业组织,我们不希望用户在【组织管理】对其做过多的改动(因为改动可以在【企业管理】完成),就做如下修改:
企业组织不需要删除按钮,给它加一个
v-if
:1
2
3
4
5
6
7
8<el-button
v-if="scope.row.higherOrg !== 0"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['enterpriseCenter:org:remove']"
>删除</el-button>企业组织本身不需要展现上级组织,加
v-if
将其隐藏;企业组织的所属企业与部门名称不希望用户在此修改,加v-if
和disabled
属性:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51<!-- 添加或修改组织管理对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
<el-col :span="24">
<el-form-item label="所属企业" prop="enterpriseId">
<el-select
v-model="form.enterpriseId"
filterable
v-if="form.higherOrg !== 0"
placeholder="请选择或搜索所属企业"
style="width: 100%">
<el-option
v-for="enter in enterOptions"
:key="enter.value"
:label="enter.label"
:value="enter.value">
</el-option>
</el-select>
<el-select
v-model="form.enterpriseId"
filterable
v-else
disabled
placeholder="请选择或搜索所属企业"
style="width: 100%">
<el-option
...
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" v-if="form.higherOrg !== 0">
<el-form-item label="上级组织" prop="higherOrg">
<treeselect
v-model="form.higherOrg"
:options="orgOptions"
:normalizer="normalizer"
placeholder="请选择或搜索上级组织" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="部门名称" prop="deptName">
<el-input v-model="form.deptName" v-if="form.higherOrg !== 0" placeholder="请输入部门名称" />
<el-input v-model="form.deptName" v-else :disabled="true" placeholder="请输入部门名称" />
</el-form-item>
</el-col>
...
</el-row>
</el-form>
</el-dialog>这样就只允许修改企业组织的地址、联系人、电话等信息。
3 设备中心
【设备中心】用于管理该公司的机床设备,以便实时监控。
3.1 变量信息
【变量信息】模块是个很简单的单表,用来存储用户的自定义变量。
3.1.1 变量绑定功能的逻辑
- 后端用Node-RED采集加入网络的机床的数据,采用饱和采集,即采集所有机床的所有数据
- 用户在【设备信息】模块加入设备,并设置数控系统id
- 用户在【变量信息】模块里自定义一个变量,并将该变量与MongoDB数据库采集到的变量名关联起来
- 用户在【设备类型】模块里对该设备的类型绑定需要的变量
- 系统根据该机床的数控系统id,拿到MongoDB里该机床的所有数据
- 前端再判断哪些变量绑定了,绑定的展示给用户,未绑定的不予展示
事实上,我们还做不到远程控制(绑定的采集,不绑定的不采集),只能饱和采集、饱和获取数据,再按需展示。
3.1.2 变量关联
自定义变量的目的,是前端展示变量数据时,可以自定义显示的变量名、变量单位等。它本身不具有任何意义,除非与MongoDB的变量关联。
MongoDB采集到的数据有特殊的变量字段名,用户自定义变量后,与该变量字段关联,就可以使用这个数据。
如MongoDB的poweronTime
采集到了数据220,前端显示:当前运行时间:220 分钟
。如果用户更改这个变量的名称与单位,则前端显示随之改变,但数值不变。(不支持单位转换,如果单位设置为秒,则还是显示220 秒
)
1 | <!-- 添加或修改变量信息对话框 --> |
mongoVariableOptions
是从数据库里拿的,因为它一般不会变化,所以没有做前端入口,但是保留了前端api调用。如果要修改,需要直接操作数据库。同时,由于自定义变量数据库存放的是mongoDB变量的id,因此表格里需要做格式化,不能显示id,要显示label
。
1 | // mongo变量列表获取 |
这里用到了find
函数,用来代替复杂的for-if
来寻找数组中符合条件的元素。否则以上代码就会写成:
1 | // 变量关联翻译 |
显然太麻烦了,尤其是在类似的操作很多的情况下。
3.2 设备类型
【设备类型】模块也是个简单单表,用来定义机床的类型,但我们在上面加了绑定变量的功能。
3.2.1 图片上传与显示
图片上传功能是若依自带的ImageUpload
组件,不需要额外设置。
图片传到数据库的是图片路径字符串,为了显示在表格里,我写了如下的代码:
1 | <el-table v-loading="loading" :data="typeList" @selection-change="handleSelectionChange"> |
图片有则显示,没有则不显示,如果不加v-if
,没有图片时会出现一个读取图片失败的方框占位,影响美观。
3.2.2 单选/多选按钮的逻辑
表格内有两类按钮,一类是表格上方带颜色的按钮,一类是表格右边每个数据项的蓝色文字按钮。这两类按钮功能基本一致。
表格右边的按钮与每个数据项关联,因此只对某个数据项起作用;表格上方的按钮与表格左边的多选框关联,因此不一定只对一个数据项起作用。像修改按钮,不允许多选操作,只允许单选操作,删除按钮则同时支持单选/多选操作。我们设计的绑定变量功能应与修改按钮类似。
若依则是通过如下方式设置这两种按钮的逻辑的:
- 表格数据项按钮传参,为当前参数;上方的按钮不传参,此时函数的参数就为
undefined
,通过一个数组ids
去处理。再通过逻辑或选择,见2.1.3节。 - 上方的按钮设置
disabled
属性为布尔值single
或multiple
,在勾选时改变其布尔值,以达到按钮启用效果。
1 | <el-row :gutter="10" class="mb8"> |
1 | data() { |
3.2.3 绑定变量
绑定变量用的是一个穿梭框,基于ElementUI的穿梭框设计。左边是变量列表,右边是已与该类型绑定的变量。
Vue部分,将穿梭框放在一个对话框里即可。
1 | <!-- 绑定变量对话框 --> |
script
部分,当用户点击某个设备类型的绑定按钮时,调用handleBind
函数:
1 | /** 绑定变量按钮操作 */ |
提前准备一个类型与变量的关联表,存储两者id。上面第6行查询该表,并把属于该类型的已绑定的变量id拿出来放在数组中备用。
用户在穿梭框内操作完毕,准备点击确定按钮时,调用bindingparamSubmitForm
函数:
1 | /** 绑定变量提交按钮 */ |
上述代码的逻辑是,判断一下现在的deviceBindingParamValue
数组与穿梭框修改后的有哪些变化,有加的就加needToAdd
,有删除的就加needToDelete
,再逐一修改。其中5-15行这里:
1 | const difference = this.deviceBindingParamValueOrigin.concat(this.deviceBindingParamValue).filter(function(v, i, arr) { |
concat
连接改变前和改变后的两个数组,filter
筛选出其中元素前后索引值相等的数组(即只出现一次的数字,就是被改变的),再通过判断这个数组中每个元素出现在改变前还是改变后的数组,确定要加还是要删。
如deviceBindingParamValueOrigin
数组(改变前)为[1, 2, 4]
,deviceBindingParamValue
数组(改变后)为[1, 4, 5]
,则concat
后为[1, 2, 4, 1, 4, 5]
,filter
后为[2, 5]
,判断后,needToAdd
为[5]
,needToDelete
为[2]
,再拿这两个数组去做真正地增删操作。
用户准备点击取消按钮时,调用bindingparamCancel
函数:
1 | // 绑定变量取消按钮 |
3.3 设备信息
【设备信息】模块也是个单表,用来存储每台具体的设备。
这个单表没有更多的技术难点。每个数据项的操作加了一个更多信息按钮,这个按钮可以跳转到该机床的监控页面,跳转是通过路由进行的,这一部分放到后面再讲。
1 | <router-link :to="`/status/device/` + scope.row.id"> |
以上【企业中心】和【设备中心】两个模块,如果不算后期不断地调整美化,只算页面和功能的大体成型,我大概做了3个星期左右。这3个星期也是边学边做的一个过程,巩固了很多Vue和JavaScript的知识,对于我的代码和debug能力有很大的提升。昆山团队退出后,我们开始忙于【工作台】与【统计报表】的制作,这花了我整整2个月。这两个模块的技术难度也不亚于这篇文章所提到的,下次有空再补充!