# 多图拖拽排序组件
分享下图片拖拽排序功能的一种实现方式
# 实现效果

# 1、主要技术栈
vue2、elementui2、vuedraggable
# 2、需求分析
产品的要求就是多图上传完后,可以对图片列表进行拖拽排序。本身elementui的el-upload组件已经支持很多功能,但是唯独没有拖拽排序,它的多图上传是按你上传时选择的文件的顺序来展示。
一般产品提出的复杂功能咱开发人员都是尽量简化或者怼回去哈哈,不过呢这个需求功能还是对用户非常友好的。。。
# 3、思路
你要用原生js手写一个拖拽功能那做起来就很复杂了,h5的draggable原生api有点鸡肋,很难实现一个比较好的交互效果,用js的mouseover不错,但是要实现最终完整的交互效果还是很费时间精力,所以还是找插件吧,通过拖拽插件和el-upload结合实现。
然后网上找到一个vuedraggable插件,github传送门 (opens new window),看demo效果还可以,那么问题来了,vuedraggable的使用方式是这样的:
<draggable v-model="myArray" group="people" @start="drag=true" @end="drag=false">
<div v-for="element in myArray" :key="element.id">{{element.name}}</div>
</draggable>
2
3
就是通过slot插槽的方式传递列表,只对插槽内第一层级的元素起作用。而el-upload使用上传后生成的列表是动态生成的且无法手动控制,也就是说vuedraggable无法作用到el-upload生成的图片列表,那就放弃使用el-upload的图片列表,自己手动写个列表盒子来展示图片,并把这个列表放在vuedraggable组件内插槽中,上传完后把获取的url地址赋值过去。 DOM结构大概如下:
<vuedraggable tag="ul" draggable=".draggable-item">
<!-- 拖拽元素 -->
<li
v-for="(item, index) in imgList"
:key="item + index"
class="draggable-item"
>
<el-image :src="item" :preview-src-list="[item]"></el-image>
</li>
<!-- 上传按钮 -->
<el-upload slot="footer">
<i class="el-icon-plus uploadIcon"></i>
</el-upload>
</vuedraggable>
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4、其他一些处理点
(1)图片删除 我做的效果是鼠标悬浮在图片上时图片右上角展示删除按钮,鼠标移下时消失,这样会有一个问题就是用鼠标拖拽完图片后可能出现拖拽之前的位置换了新图片但删除按钮还在,处理方式就是给vuedraggable绑定拖拽开始和拖拽结束事件,拖拽开始时添加隐藏删除按钮的类名,使拖拽过程中都不显示删除按钮,拖拽结束再移除这个类名恢复正常。
onDragStart (e) {
e.target.classList.add('hideShadow')
},
onDragEnd (e) {
e.target.classList.remove('hideShadow')
}
2
3
4
5
6
(2)图片预览 单击图片预览即可,我这里使用了el-image的组件,设置preview-src-list属性就可以实现预览,但是它的预览会保留预览时的状态,包括图片翻页的位置,所以这里就不要它的图片翻页功能了,直接通过数组[]包裹下该图片的地址字符串。
<el-image :src="item" :preview-src-list="[item]"></el-image>
(3)上传数量限制 由于图片上传仍然使用的el-upload组件,而图片上传是接口请求异步的,所以无法通过判断图片展示列表数量来控制图片超限,那还是继续使用el-upload自带的上传限时功能吧,也就是绑定on-exceed属性。
需要处理的一点是在图片展示列表删除单张图片后要同步下el-upload组件里上传完的图片数据,这样它才能正确判断数量是否超限,而它的图片数据都存储在el-upload元素的uploadFiles属性里,这个属性在elementui官方文档里没有说明,可以通过给el-upload绑定一个ref属性,通过this.$refs.uploadRef.uploadFiles获取,里面是一个数组,数组里每一项是个对象,有name、url、status、uid四个属性,uid需要保证值的唯一性,
# 5、使用方式示例
引入封装好的组件imgUpload,可以配合element的表单组件el-form:
<el-form
label-position="right"
label-width="120px"
:model="formData"
ref="formRef"
>
<el-form-item label="文章图片:" prop="imgList">
<dragUploadImg
@uploaddata="uploaddata"
v-model="formData.imgList"
ref="upload"
:limit="6">
</dragUploadImg>
</el-form-item>
</el-form>
......
<script>
......
export default {
data () {
return {
formData: {
imgList: []
}
}
},
methods: {
uploaddata (e) {
console.log(e)
}
}
......
}
</script>
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
# 6、组件完整代码
组件本身对外暴露了很多配置项,包括一些配置项都依赖el-upload组件,所以这也是没有自己手写上传功能的主要原因。 el-upload标签的action属性是图片上传接口地址,记得换成你们公司自己的稳定接口,然后根据你们接口返回的数据格式修改onSuccessUpload方法里的处理。 完整代码imgUpload.vue:
<template>
<div class="uploadWrapper">
<vuedraggable class="vue-draggable"
:class="{ single: isSingle, maxHidden: isMaxHidden }"
v-model="imgList"
force-fallback="true"
group="site"
tag="ul"
draggable=".draggable-item"
@start="onDragStart"
@end="onDragEnd">
<!-- 拖拽元素 -->
<li v-for="(item, index) in imgList"
:key="item + index"
class="draggable-item"
:style="{ width: width + 'px', height: height + 'px' }">
<el-image :src="item"
:preview-src-list="[item]"></el-image>
<div class="shadow"
@click="onRemoveHandler(index)">
<i class="el-icon-delete"></i>
</div>
</li>
<!-- 上传按钮 -->
<el-upload slot="footer"
ref="uploadRef"
class="uploadBox"
:style="{ width: width + 'px', height: height + 'px' }"
:action="uploadUrl"
accept=".jpg,.jpeg,.png,.gif"
:show-file-list="false"
:multiple="!isSingle"
:limit="limit"
:before-upload="beforeUpload"
:on-success="onSuccessUpload"
:on-exceed="onExceed">
<i class="el-icon-plus uploadIcon">
<span class="uploading"
v-show="isUploading">正在上传...</span>
<span v-if="!isUploading && limit && limit!==99 && !isSingle"
class="limitTxt">最多{{ limit }}张</span>
</i>
</el-upload>
</vuedraggable>
</div>
</template>
<script>
// 可拖拽上传图片组件
import vuedraggable from 'vuedraggable'
import { validImgUpload } from '@/modules/index/utils/validateImg'
import lrz from 'lrz' // 前端图片压缩插件
import tools from '@/modules/index/utils/tools'
export default {
name: 'dragImgUpload',
components: { vuedraggable },
props: {
// 图片数据(图片url组成的数组) 通过v-model传递
value: {
type: Array,
default () {
return []
}
},
// 限制上传的图片数量
limit: {
type: Number,
default: 99
},
// 限制上传图片的文件大小(kb)
size: {
type: Number,
default: 500
},
// 是否是单图上传(单图上传就是已传图片和上传按钮重叠)
isSingle: {
type: Boolean,
default: false
},
// 是否使用图片压缩
useCompress: {
type: Boolean,
default: false
},
// 图片显示的宽度(px)
width: {
type: Number,
default: 100
},
// 图片显示的高度(px)
height: {
type: Number,
default: 100
}
},
data () {
return {
uploadUrl: process.env.BASE_API + '/api/account/common/upload-file',
isUploading: false, // 正在上传状态
isFirstMount: true // 控制防止重复回显
}
},
computed: {
// 图片数组数据
imgList: {
get () {
return this.value
},
set (val) {
if (val.length < this.imgList.length) {
// 判断是删除图片时同步el-upload数据
this.syncElUpload(val)
}
// 同步v-model
this.$emit('input', val)
}
},
// 控制达到最大限制时隐藏上传按钮
isMaxHidden () {
return this.imgList.length >= this.limit
}
},
watch: {
// 监听imgList, 暴露给父组件
imgList (now) {
if (now) {
this.$emit('uploaddata', this.imgList)
}
},
value: {
handler (val) {
if (this.isFirstMount && this.value.length > 0) {
this.syncElUpload()
}
},
deep: true
}
},
mounted () {
if (this.value.length > 0) {
this.syncElUpload()
}
},
methods: {
// 同步el-upload数据
syncElUpload (val) {
const imgList = val || this.imgList
this.$refs.uploadRef.uploadFiles = imgList.map((v, i) => {
return {
name: 'pic' + i,
url: v,
status: 'success',
uid: tools.createUniqueString()
}
})
this.isFirstMount = false
},
// 上传图片之前
beforeUpload (file) {
this.isFirstMount = false
if (this.useCompress) {
// 图片压缩
return new Promise((resolve, reject) => {
lrz(file, { width: 1920 }).then((rst) => {
file = rst.file
}).always(() => {
if (validImgUpload(file, this.size)) {
this.isUploading = true
resolve()
} else {
reject(new Error())
}
})
})
} else {
if (validImgUpload(file, this.size)) {
this.isUploading = true
return true
} else {
return false
}
}
},
// 上传完单张图片
onSuccessUpload (res, file, fileList) {
// 这里需要根据你自己的接口返回数据格式和层级来自行修改
if (res) {
// 判断接口上传成功
if (this.imgList.length < this.limit) {
// 未超限时,把接口返回的图片url地址添加到imgList
this.imgList.push(res)
}
} else {
// 判断接口上传失败
this.syncElUpload()
this.$message({ type: 'error', message: '上传图片失败' })
}
this.isUploading = false
},
// 移除单张图片
async onRemoveHandler (index) {
const confirmResult = await this.$confirm(
"确定删除该图片?",
"提示",
{
confirmButtonText: "确认删除",
cancelButtonText: "取消",
type: "warning"
}
).catch(err => err);
if (confirmResult != "confirm") {
return this.$message.info("已经取消删除");
}
const i = this.imgList.findIndex((item, i) => i === index)
this.imgList.splice(i, 1)
},
// 超限
onExceed () {
this.$refs.uploadRef.abort() // 取消剩余接口请求
this.syncElUpload()
this.$message({
type: 'warning',
message: `图片超限,最多可上传${this.limit}张图片`
})
},
onDragStart (e) {
// 隐藏
e.target.classList.add('hideShadow')
},
onDragEnd (e) {
e.target.classList.remove('hideShadow')
// 手动修改图片排列
let item = this.imgList.splice(e.oldDraggableIndex, 1)
this.imgList.splice(e.newDraggableIndex, 0, item[0])
},
getSrcList(item) {
this.imgList.push(item)
this.syncElUpload()
}
},
}
</script>
<style lang="scss" scoped>
ul,
li {
padding: 0;
margin: 0;
list-style: none;
}
/deep/ .el-upload {
width: 100%;
height: 100%;
}
/deep/ .el-icon-circle-close {
color: #fff;
}
// 上传按钮
.uploadIcon {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #c0ccda;
background-color: #fbfdff;
border-radius: 6px;
font-size: 20px;
color: #999;
.limitTxt,
.uploading {
position: absolute;
bottom: 10%;
left: 0;
width: 100%;
font-size: 14px;
text-align: center;
}
}
// 拖拽
.vue-draggable {
display: flex;
flex-wrap: wrap;
.draggable-item {
margin-right: 5px;
margin-bottom: 5px;
border: 1px solid #ddd;
border-radius: 6px;
position: relative;
overflow: hidden;
.el-image {
width: 100%;
height: 100%;
}
.shadow {
position: absolute;
top: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
color: #fff;
font-size: 20px;
line-height: 20px;
padding: 2px;
cursor: pointer;
}
&:hover {
.shadow {
opacity: 1;
}
}
}
&.hideShadow {
.shadow {
display: none;
}
}
&.single {
overflow: hidden;
position: relative;
.draggable-item {
position: absolute;
left: 0;
top: 0;
z-index: 1;
}
}
&.maxHidden {
.uploadBox {
display: none;
}
}
}
// el-image
.el-image-viewer__wrapper {
.el-image-viewer__mask {
opacity: 0.8;
}
.el-icon-circle-close {
color: #fff;
}
}
</style>
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# 7、附录
(1)组件里用到的工具函数 tools.js:
/**
* 创建唯一的字符串
* @return {string} ojgdvbvaua40
*/
function createUniqueString () {
const timestamp = +new Date() + ''
const randomNum = parseInt((1 + Math.random()) * 65536) + ''
return (+(randomNum + timestamp)).toString(32)
}
/**
* 数字存储大小格式化
* @param {number} num 存储大小 单位:Byte
* @param {number} digits 保留几位小数
* @return {string} 2MB
*/
function toStorage (num, digits) {
digits = digits || 2
if (num < 1024) {
return num + 'B'
}
num = (num * 1000 / 1024)
const si = [
{ value: 1E18, symbol: 'E' },
{ value: 1E15, symbol: 'P' },
{ value: 1E12, symbol: 'T' },
{ value: 1E9, symbol: 'G' },
{ value: 1E6, symbol: 'M' },
{ value: 1E3, symbol: 'K' }
]
for (let i = 0; i < si.length; i++) {
if (num >= si[i].value) {
return (num / si[i].value).toFixed(digits).replace(/\.0+$|(\.[0-9]*[1-9])0+$/, '$1') +
si[i].symbol + 'B'
}
}
}
export default {
createUniqueString,
toStorage
}
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
(2)组件中用到的validate.js:
import tools from '@/utils/tools'
/**
* 图片上传
* @param {file} file el-upload文件对象
* @param {number} size 限制的文件大小(kb) 默认10M
*/
export const validImgUpload = (file, size) => {
size = +size || 10240
const isSizeOut = file.size / 1024 > size
if (isSizeOut) {
Message.error('上传图片大小不能超过' + tools.toStorage(size * 1024))
}
return !isSizeOut
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15