背景
先说下需求,为了提升开发效率和降低开发成本和技术难度,采用低代码方式,通过配置来生成代码,算是代码生成器的升级版解决方案。
对于前端页面,某个业务实体,如系统参数,常见的菜单对应一个列表页面,顶部为页面级功能按钮,如新增、删除、导出等,中间为查询区域,可以放几个常用的查询条件,最下面则是查询结果,以表格形式展现行列数据。此外,为了方便用户操作,往往查询结果表格的行记录,最后一列放一些针对于该行数据的快捷按钮,如删除、编辑等,如下图所示
注:上图中将页面级功能按钮放到了查询区域与查询结果之间。
要实现上面页面的配置,需要进行抽象,划分出四个可配置的区域,即页面按钮、查询条件、查询列表、行按钮,效果图如下:
技术方案上,通过配置,结合模板技术,实现列表视图页面前端代码的自动化生成。
上面这个配置功能,一方面,涉及到元素的排序,如按钮的次序、查询条件的次序、查询结果中列的次序;另一方面,涉及到列表间元素的移动,如将实体属性添加到查询列表或查询结果中。如采用传统模式,需要选中某个元素,点击左移、右移等按钮,既不直观,操作也繁琐。而采用拖拽式操作,所见即所得,则用户体验大幅提升。
Element UI 提供了大部分UI控件,但对于拖拽,确实是个短板,于是就需要额外的控件来补充了,即今天的主角vuedraggable。
简介
Draggable为基于Sortable.js的vue组件,用以实现拖拽功能。
特性
- 支持触摸设备
- 支持拖拽和选择文本
- 支持智能滚动
- 支持不同列表之间的拖拽
- 不以jQuery为基础
- 和视图模型同步刷新
- 和vue2的过渡动画兼容
- 支持撤销操作
- 当需要完全控制时,可以抛出所有变化
- 可以和现有的UI组件兼容
官方地址:https://github.com/SortableJS/Vue.Draggable
中文版文档:https://www.itxst.com/vue-draggable/tutorial.html
安装
npm install vuedraggable -S
引用
import Draggable from ‘vuedraggable’
实战
接下来我会从实战角度介绍如何实现我们的功能,用到的关键属性和方法,需要注意的事项,坑点以及解决方案。
拖动排序
以页面级功能按钮为例,实现效果如下:
前端源码如下:
<template>
<el-row :gutter="20" type="flex">
<el-col :span="24">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>页面按钮配置</span>
<span style="float:right">
<el-button style=" padding: 1px 0" icon="el-icon-plus" @click="add" />
<el-button style=" padding: 1px 0" icon="el-icon-delete" @click="clear" />
</span>
</div>
<draggable v-model="buttonList" :group="group" @sort="updateSort" @update="update">
<el-row v-for="(item) in buttonList" :key="item.code">
<el-col>
<el-tag closable @close="remove(item.id)" @click="modify(item.id)"> {
{ item.name }}</el-tag>
</el-col>
</el-row>
</draggable>
<detail ref="detail" :custom-parent-id="this.$route.query.id" :button-type="$constant.BUTTON_TYPE_PAGE" @ok="handleDetailSave" />
</el-card>
</el-col>
</el-row>
</template>
<script>
import Detail from './detail'
import Draggable from 'vuedraggable'
export default {
components: { Detail, Draggable },
data() {
return {
entityViewId: '',
buttonList: [],
group: {
name: 'pageButton',
pull: false,
put: false
}
}
},
mounted() {
this.init()
},
methods: {
// 初始化
init() {
this.entityViewId = this.$route.query.id
this.query()
},
// 新增
add() {
this.$refs.detail.add()
},
// 修改
modify(id) {
this.$refs.detail.modify(id)
},
// 移除
remove(id) {
this.$confirm('此操作将移除页面按钮, 是否继续?', '确认', {
type: 'warning'
}).then(() => {
this.$api.entityconfig.entityViewButton.remove(id)
.then(() => {
this.query()
})
}).catch(() => {
this.$message.info('已取消')
})
},
// 清空
clear(id) {
this.$confirm('此操作将移除所有页面按钮,已配置信息丢失且不可恢复,是否继续?', '确认', {
type: 'warning'
}).then(() => {
this.$api.entityconfig.entityViewButton.clear(id)
.then(() => {
this.query()
})
}).catch(() => {
this.$message.info('已取消')
})
},
// 加载按钮列表
query() {
this.$api.entityconfig.entityViewButton.listByViewAndType(this.entityViewId, this.$constant.BUTTON_TYPE_PAGE).then(res => {
this.buttonList = res.data
})
},
// 更新次序
updateSort(evt) {
evt.preventDefault()
const sortedButtonList = this.buttonList.map(function (value, index) {
return { 'index': index, 'code': value.code }
})
this.$api.entityconfig.entityViewButton.updateButtonSort(this.entityViewId, sortedButtonList)
},
handleDetailSave() {
this.query()
}
}
}
</script>
<style>
</style>
这里组合了Element UI的多种控件,包括el-row、el-col、el-card、el-tag,与vuedraggable相关的核心代码如下:
<draggable v-model="buttonList" :group="group" @update="updateSort">
<el-row v-for="(item) in buttonList" :key="item.code">
<el-col>
<el-tag closable @close="remove(item.id)" @click="modify(item.id)"> {
{ item.name }}</el-tag>
</el-col>
</el-row>
</draggable>
关键属性,就是v-model,绑定了一个对象数组,也就是组件的数据源,这是vue的标准作法,没什么好说的。
至于group属性,如果单看这一个组件实际可以不用设置,但全局来看不设置会有坑点,后面再说,这里可以忽略。
组件跟排序相关的事件有两个,一个是update,一个是sort,我们应该用哪个呢?
从官方描述很难看出差异:update:拖拽变换位置时触发的事件;sort:位置变化时的事件。
自己测试了一下,在当前这个场景下,拖拽元素改变次序,两个事件都能触发。数据源数组新增或删除元素时,两个事件都不触发,在这个场景下,用哪个都行,这里选用了update事件。
// 更新次序
updateSort(evt) {
evt.preventDefault()
const sortedButtonList = this.buttonList.map(function (value, index) {
return { 'index': index, 'code': value.code }
})
this.$api.entityconfig.entityViewButton.updateButtonSort(this.entityViewId, sortedButtonList)
},
evt.preventDefault()的作用是防止某些浏览器,如firefox,将拖动视为下载。
vuedraggable干的是前端的排序的活,最终还是需要传到后端处理的。因此后面几行是将唯一性的数据编码和当前索引值,处理成对象数组,传到后端,由后端更新排序号,从而将次序调整持久化。
列表间拖动
实现效果图如下:
这实际上是三个vuedraggable组件的组合,最外围是下面这个样子:
<template>
<el-row :gutter="20" type="flex">
<el-col :span="5"> <page-button /></el-col>
<el-col :span="5"> <property-list @getAllPropertyData="getAllPropertyData" @refreshQueryCondition="refreshQueryCondition" @refreshQueryResult="refreshQueryResult" /></el-col>
<el-col :span="9">
<el-row :gutter="20" type="flex">
<el-col :span="24"> <query-area ref="queryCondition" :property-list-data="allPropertyList" /></el-col>
</el-row>
<el-row :gutter="20" type="flex">
<el-col :span="24"> <result-area ref="queryResult" :property-list-data="allPropertyList" /></el-col>
</el-row>
</el-col>
<el-col :span="5"> <row-button /></el-col>
</el-row>
</template>
每个区域拆成了一个独立的vue页面,开始的时候还担心,这种跨页面列表间的元素移动,有可能不支持,实际测了下,没有问题,这点很好。
按照官方文档说明,如果要在列表间拖动,则需要设置group属性,group可以是一个单属性,也可以是一个对象,该对象的name属性需要相同。
//设置方式一,直接设置组名
group:'list'
//设置方式二,object,也可以通过自定义函数function实现复杂的逻辑
group:{
name:'list',
pull: true|false| 'clone'|array|function,//是否允许拖出当前组
put:true|false|array|function,//是否允许拖入当前组
}
在我的设计中,实体属性列表是从实体配置中读取出来的,既可以添加到右上方的查询条件列表,又可以添加到右下方的查询结果列表,因此设置如下:
allPropertyGroup: {
name: 'list',
pull: 'clone',
put: false
}
name是组名,put为false是禁止通过拖拽方式添加元素。
pull属性值需要注意,在当前场景下,需要设置为clone,即拷贝,意思是将当前元素拷贝一份,放到目的地列表,如果设置为true,相当于移动,如果设置为false,相当于禁止拖出当前列表。
查询条件和查询结果本质上一样的,我这边以查询条件为例说明,如何接收从左侧实体属性列表拖动过来的元素。
<draggable v-model="queryConditionList" :group="group" style="height:200px" @add="addFromModelProperty" @update="updateSort">
<el-row v-for="(item) in queryConditionList" :key="item.code">
<el-col> <el-tag closable @close="remove(item.id)" @click="modify(item.id)">{
{ item.name }}</el-tag>
</el-col>
</el-row>
</draggable>
首先,是设置group属性,名字保持与左侧列表一致,都叫list,pull设置为false,禁止拖出当前列表,而put这时候要给true,即接受拖入。
group: {
name: 'list',
pull: false,
put: true
}
然后关键的事件是add,这是拖入后触发的事件,需要注意的是,这个事件,携带的evt参数虽然很庞大,但里面放的东西,是前端ui元素,而不是我们期望的数据。例如,我希望拿到实体属性的编码,通过这个编码,找到实体属性,然后拷贝其他相关数据,插入到查询列表的库表中去,从evt参数中,拿不到这些信息。
这时候,就通过曲线救国的方式来实现了。从evt参数中,可以拿到拖动元素的来源列表处的索引oldIndex,这样结合来源列表的绑定的数据对象,就能拿到我们期望的数据了。
// 新增
addFromModelProperty(evt) {
const code = this.propertyListData[evt.oldIndex].code
this.$api.entityconfig.viewQueryCondition.addFromModelProperty(this.entityViewId, code)
.then(() => this.sort())
},
在这个场景下,就能测试出sort事件和update事件的差别来了,如果是从别的列表拖动元素过来,则只会触发sort事件,不会触发update。也就是说,update事件仅在列表内部拖动改变次序时才会触发,而sort在拖入元素时也会触发。
既然sort触发的时机更多,那我这边为什么没有使用sort而依然是使用update呢?
因为sort在我们这个场景下是有问题的,我们是左侧属性列表拖动实体属性到右侧的查询条件,逻辑处理是先根据编码,拷贝属性在查询条件中新建一行记录。而sort事件会在拖动对象一放下就触发,然后传入当前的属性列表和索引值,而这时候,数据插入动作尚未完成,新拖入的元素去更新排序号会报对象不存在的异常。
如何解决呢?其实也简单,我们不使用sort,一方面仍然使用update来处理列表内部拖动排序,另一方面在通过拖动新增元素的add事件中,通过promise函数,在数据插入处理成功后,再调用一次更新次序的后端操作。
// 新增
addFromModelProperty(evt) {
const code = this.propertyListData[evt.oldIndex].code
this.$api.entityconfig.viewQueryCondition.addFromModelProperty(this.entityViewId, code)
.then(() => this.sort())
}
},
// 拖拽结束
updateSort(evt) {
evt.preventDefault()
this.sort()
},
// 排序
sort() {
const sortedList = this.queryConditionList.map(function (value, index) {
return { 'index': index, 'code': value.code }
})
this.$api.entityconfig.viewQueryCondition.updateSort(this.entityViewId, sortedList)
},
附上查询条件区域的完整源码供参考
<template>
<el-row :gutter="20">
<el-col :span="24">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span>查询条件</span>
<span style="float:right">
<el-button style=" padding: 1px 0" icon="el-icon-plus" @click="add" />
<el-button style=" padding: 1px 0" icon="el-icon-delete" @click="clear" />
</span>
</div>
<draggable v-model="queryConditionList" :group="group" style="height:200px" @add="addFromModelProperty" @update="updateSort">
<el-row v-for="(item) in queryConditionList" :key="item.code">
<el-col> <el-tag closable @close="remove(item.id)" @click="modify(item.id)">{
{ item.name }}</el-tag>
</el-col>
</el-row>
</draggable>
</el-card>
<detail ref="detail" :custom-parent-id="this.$route.query.id" @ok="handleDetailSave" />
</el-col>
</el-row>
</template>
<script>
import Detail from './detail'
import Draggable from 'vuedraggable'
export default {
components: { Detail, Draggable },
props: {
propertyListData: {
type: Array,
default: () => [],
required: false
}},
data() {
return {
entityViewId: '',
queryConditionList: [],
currentConditionId: '',
group: {
name: 'list',
pull: false,
put: true
}
}
},
mounted() {
this.init()
},
methods: {
// 初始化
init() {
this.entityViewId = this.$route.query.id
this.query()
},
// 新增
add() {
this.$refs.detail.add()
},
// 新增
addFromModelProperty(evt) {
const code = this.propertyListData[evt.oldIndex].code
this.$api.entityconfig.viewQueryCondition.addFromModelProperty(this.entityViewId, code)
.then(() => this.sort())
},
// 修改
modify(id) {
this.currentConditionId = id
this.$refs.detail.modify(this.currentConditionId)
},
// 移除
remove(queryConditionId) {
this.$confirm('此操作将移除查询条件, 是否继续?', '确认', {
type: 'warning'
}).then(() => {
this.$api.entityconfig.viewQueryCondition.remove(queryConditionId)
.then(() => {
this.query()
})
}).catch(() => {
this.$message.info('已取消')
})
},
// 加载列表
query() {
this.$api.entityconfig.viewQueryCondition.listByView(this.entityViewId).then(res => {
this.queryConditionList = res.data
})
},
// 拖拽结束
updateSort(evt) {
evt.preventDefault()
this.sort()
},
// 排序
sort() {
const sortedList = this.queryConditionList.map(function (value, index) {
return { 'index': index, 'code': value.code }
})
this.$api.entityconfig.viewQueryCondition.updateSort(this.entityViewId, sortedList)
},
// 清空
clear() {
this.$confirm('此操作将移除所有查询条件,已配置信息丢失且不可恢复,是否继续?', '确认', {
type: 'warning'
}).then(() => {
this.$api.entityconfig.viewQueryCondition.clear(this.entityViewId)
.then(() => {
this.query()
})
}).catch(() => {
this.$message.info('已取消')
})
},
handleDetailSave() {
this.query()
}
}
}
</script>
<style>
.el-row {
margin-bottom: 10px;
}
</style>
接下来,面临的一个问题,如何处理属性重复添加问题。
vuedraggable只要拖放,立马就能看到效果,例如,从左侧实体属性列表,拖放到右侧查询条件。但在这个场景下,实际上,需要判断下右侧属性列表是否已存在,如不存在,则允许添加,如存在,则不再添加。后端的验证处理是小case,就不在这里多说了,关键是前端该怎么处理。
看了半天官方文档,没找到合适的控制,又百度了半天,也没有满意的结果,自行摸索,终于发现move事件可以用。实际上,官方对于move的定位,是用来自定义控制那些元素可以拖拽或不允许拖拽并控制是否允许停靠的。我们这里就是希望控制是否允许停靠。
//move回调方法
onMove(e,originalEvent){
console.log(e);
console.log(originalEvent);
//false表示阻止拖拽
return true;
},
首先,一个坑点是,move虽然是事件,但不是我们常用的,用@move='move’来触发,而是使用了属性绑定的方式,用:move=‘move’,这点有点反常理,我就卡了一会,发现事件不触发,详细看文档才发现问题在这。
其次,在我们这个场景下,move属性,绑在左侧实体属性组件,还是右侧查询条件组件上,从是否允许停靠描述看,好像应该放到右侧,但实际测试发现,从左侧拖到右侧,根本就不触发,因此这个move,实际是针对源列表而言的。
再次,实际测试发现,存在触发多次问题,怀疑跟前端的事件冒泡机制有关,摸索了半天,也没找到只触发一次的办法。如有人填过这个坑,烦请评论中告知处理方式,先行谢过。
// 移动
move(e) {
// TODO 存在触发多次问题
const code = e.draggedContext.element.code
const list = e.relatedContext.list
const exist = list.some(item => { return item.code === code })
// if (exist) {
// this.$message.info('已存在,请勿重复添加')
// }
return !exist
}
这个事件比较不错的是,期望的数据都能从事件参数中拿到,比如e.draggedContext.element,可以拿到拖动元素绑定的数据对象, e.relatedContext.list可以拿到目标列表的数据,二者做个简单对比,就能得出是否属于重复添加,如是,方法返回false,即可终止拖动这个动作。
2023-04-17 多次触发move优化
引入了第三方工具类throttle,来限制move的触发次数,详见博文https://blog.csdn.net/seawaving/article/details/130157163
import {
throttle } from 'lodash';
export default {
methods: {
handleMove: throttle(function(event) {
// 处理拖动事件
}, 100)
}
}
思考
回过头来思考了一下这个问题,如果想从根源上实现属性在列表间拖动,只触发一次效果,那应该是vuedraggable组件提供相应的事件,即拖动到指定位置后松开鼠标左键时触发。
vuedraggable组件有end事件,但是这个事件不像move,返回false可以取消操作,而是一定会成功,即从前端看,把一个属性,从左侧列表拖到了右侧列表,无论右侧列表是否已存在。虽然可以从后端去验证和处理,并刷新右侧列表,但不可避免还会出现先出现后消失的闪烁情况,效果并不好。
当初就是发现end事件不能取消拖拽,才拿move作为曲线救国方案来实现的。因此,当下最优解决方案,实际就是使用throttle函数,来减少触发次数,经反复试验,将间隔时间设置为500毫秒比较合适,能大幅减少触发次数,且不会让拖拽操作显得卡顿。
顺便提一下,期待vuedraggable组件将来能改造下end事件,变成可取消,才能完美解决该问题。
最后,再说一个意料之外的坑,按照官方文档描述,不同列表间拖动,需要设置group属性,并保证name属性一致,但是……在测试过程中,无意中发现,我居然能把页面级按钮,拖放到查询条件列表中,明明组名不同……后来,不得不给页面按钮的组件,打了个补丁,限制其元素拖出列表来解决这个问题。
group: {
name: 'pageButton',
pull: false,
put: false
}
2022-12-08 测试出一个新的bug,补充说明下。
从实体属性列表中,选中一个属性,拖到查询条件列表中,这一步操作正常,但是在查询条件列表中,点击这个刚添加的属性,打开编辑窗口时,会报无法找到数据的错误,经排查,问题出在id这个字段上,拖过来的字段,id是实体属性的,放到新列表中,这个id并没有变化,而查询条件列表中编辑页面,是通过id去拿数据的,从而发生无法找到对象的问题……这个坑比较隐蔽。
原因找到了,解决方式也比较容易,在排序方法中,再调用一次查询接口,从远程拿数据后,覆写列表的数据源即可。
// 排序
sort() {
const sortedList = this.queryConditionList.map(function (value, index) {
return {
'index': index, 'code': value.code }
})
this.$api.entityconfig.viewQueryCondition.updateSort(this.entityViewId, sortedList).then(() => {
this.query()
})
},
// 加载列表
query() {
this.$api.entityconfig.viewQueryCondition.listByView(this.entityViewId).then(res => {
this.queryConditionList = res.data
})
},