基于Sortablejs表格行拖拽
本文所用到的包的版本号
lua
"xe-utils": "^3.5.11"
"sortablejs": "^1.15.0",
"vxe-table": "^3.6.17",
"element-ui": "^2.4.5",
"element-ui-el-table-draggable": "^1.2.10",
功能点
- 表格行可以拖拽
- 拖拽完成后对应的数据进行同步
- 拖拽完成保存到服务器失败,数据进行同步, UI复位
- 拖拽的时候鼠标形状应该变成抓手状
Element ui Table
在实现element ui table 拖拽的时候最开始选用的 element-ui-el-table-draggable
这个库, 但是这个库在拖拽完成后会闪烁一下。 翻了下源代码
会在onEnd的时候重新设置一个key。导致重渲染造成的。作者可能是考虑到表格行高不固定(此为猜测),导致滚动条和对应dom不一致。
源代码挺简单的, 所以自己就拷贝了一份放进项目里面当组件使用了。
xml
<!-- 使element 表格可以拖拽
第一个子元素必须是表格
表格必须设置rowKey
-->
<template>
<div ref="wrapperRef">
<slot></slot>
</div>
</template>
<script lang="ts">
import { Component, Vue, Ref } from 'vue-property-decorator';
import Sortable from 'sortablejs';
import type { ElTable } from 'element-ui/types/table';
@Component
export default class SortableTableWrapper extends Vue {
@Ref()
wrapperRef!: HTMLDivElement
mounted() {
this.makeTableSortable();
}
makeTableSortable() {
const table = this.$children[0].$el.querySelector('.el-table__body-wrapper tbody');
if (table) {
Sortable.create(table as HTMLTableElement, {
chosenClass: 'chosen',
onStart: ({ target }) => {
setTimeout(() => {
target.classList.add('sortable-drag'); // 设置抓手样式
}, 50);
},
onEnd: ({ newIndex, oldIndex, target }) => {
// element table 的data是props,拖拽失败只需要在父组件处理
// 拖拽成功父组件也不需要处理数据
target.classList.remove('sortable-drag');
if (newIndex === oldIndex) return;
const arr = (this.$children[0] as ElTable).data;
const originData = [...arr];
const targetRow = arr.splice(oldIndex!, 1)[0];
arr.splice(newIndex!, 0, targetRow);
this.$emit('drop', {
targetObject: targetRow,
list: arr,
originData: originData,
newIndex,
oldIndex,
});
},
});
}
}
}
</script>
<style lang="scss" scoped>
</style>
由于Element Table 渲染的数据直接用的是Props传过来的数据。 所以拖拽成功,失败后不用在组件内进行数据处理。 直接在对应的父组件处理表格数据就行。
如何下拉加载数据
表格数据可能会很多,所以需要进行分页加载。element table 底部有一个slot。可以在这个slot里面添加一个加载中的元素,然后监听滚动事件或者IntersectionObserver
进行处理。 本文章不进行阐述
Table Slot
name | 说明 |
---|---|
append | 插入至表格最后一行之后的内容,如果需要对表格的内容进行无限滚动操作,可能需要用到这个 slot。若表格有合计行,该 slot 会位于合计行之上。 |
Vxe Table 的行拖拽实现
出于数据量大的原因,需要用到虚拟表格, 所以就选取了vxe table。element ui table 配合virtul-scroll也可以实现。 但是element ui table 如果要行高相同, 就需要设置每个字段。 挺多的。 然后做虚拟表格的时候还可能产生其它的问题。时间上来不急。但是vxe table 自带虚拟表格, 可以避免掉一些坑。
完整代码实现
xml
<!-- Vxe table, 虚拟滚动, 可拖拽 -->
<template>
<div>
<el-card shadow="never">
<div slot="header" class="flex items-center justify-between">
<span>{{ title || '列表' }}</span>
<div class="flex items-center justify-between">
<slot></slot>
</div>
</div>
<vxe-table
:height="height"
ref="tableRef"
:loading="loading"
:row-config="{
keyField: rowKey,
useKey: true,
height: rowHeight,
}"
show-overflow
@scroll="onHandleScroll"
>
<template v-for="item in table">
<!-- 文字类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'text'"
:align="item.align || 'center'"
></vxe-column>
<!-- 头像类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'avatar'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<el-avatar
:src="row[item.value]"
:size="item.size || 60"
:shape="item.shape || 'circle'"
:fit="item.fit || 'cover'"
></el-avatar>
</template>
</vxe-column>
<!-- 图片类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'image'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<div class="h-100 flex items-center justify-center">
<el-image
:src="row[item.value]"
class="photo"
fit="contain"
:style="{ height: rowHeight - 10 + 'px' }"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline"></i>
</div>
</el-image>
</div>
</template>
</vxe-column>
<!-- 哈希类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'hash'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
{{ item.options[row[item.value]] }}
</template>
</vxe-column>
<!-- 数组类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'array'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<div
v-for="(m, n) in row[item.value]"
:key="n"
>{{ item.key ? m[item.key] : m }}</div>
</template>
</vxe-column>
<!-- 日期时间类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'datetime'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<template v-if="row[item.value]">
{{ row[item.value] | dateTime }}
</template>
</template>
</vxe-column>
<!-- 日期类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'date'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<template v-if="row[item.value]">
{{ row[item.value] | date }}
</template>
</template>
</vxe-column>
<!-- 布尔类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'boolean'"
:align="item.align || 'center'"
>
<template v-slot="{ row }" v-if="item.options && item.options.length">
{{ row[item.value] ? item.options[0] : item.options[1] }}
</template>
</vxe-column>
<!-- 操作类型 -->
<vxe-column
:title="item.name"
:key="item.name"
:field="item.value"
:width="item.width"
v-if="item.type === 'actions'"
:align="item.align || 'center'"
>
<template v-slot="{ row }">
<template v-for="(x, y) in item.actions">
<el-button
v-if="!x.connect"
:key="y"
type="text"
:class="[`btn--${x.type}`]"
@click="x.actions(row)"
size="small"
:disabled="x.disabled ? x.disabled(row) : undefined"
>{{ x.name.call ? x.name.call(undefined, row) : x.name }}</el-button
>
<template v-else>
<el-button
v-for="(i, j) in x.children[row[x.connect]]"
:key="j + Math.random"
type="text"
:class="[`btn--${x.type}`]"
@click="item.actions(row)"
size="small"
:disabled="x.disabled ? x.disabled(row) : undefined"
>{{ item.name.call ? item.name.call(undefined, row) : item.name }}</el-button
>
</template>
</template>
</template>
</vxe-column>
</template>
</vxe-table>
</el-card>
</div>
</template>
<script lang="ts">
import type { IVxeTableScroll } from '@/models/VxeTable';
import type { IVxeTableConfig } from '@/models/Component';
import {
Component, Vue, Prop, Ref,
Watch,
} from 'vue-property-decorator';
import { Table } from 'vxe-table';
import Sortable from 'sortablejs';
import { ISortDropEmit } from '@/models/Sort';
@Component
export default class VxeSortableTable extends Vue {
@Prop() height?: number // 设置height 后可自动开启虚拟滚动
@Prop() title?: string
@Prop({ default: false }) loading!: boolean
@Prop({ default: 'id' }) rowKey!: string
@Prop({ default: () => [] }) data!: Record<string, any>[]
@Prop({ required: true }) rowHeight!: number
@Prop({ default: true }) sortable!: boolean
@Watch('data')
onDataChange() {
console.log('loadDAta');
this.loadData();
}
@Prop({ required: true }) table!: IVxeTableConfig<any>[]
@Ref() tableRef!: Table
dropData = {
oldIndex: -1, // 完整数据的索引
newIndex: -1, // 完整数据的索引
chooseRow: null as null | Record<string, any>, // 移动的元素
placedRow: null as null | Record<string, any>, // 移动后元素
}
mounted() {
if (this.sortable) {
this.makeTableSortable();
}
}
/** 处理表格数据 */
loadData() {
this.tableRef.loadData(this.data);
}
/** 初始化拖拽 */
makeTableSortable() {
const el = this.tableRef.$el.querySelector('.vxe-table--body tbody');
if (!el) {
throw new Error('找不动vxe table tbody');
}
Sortable.create(el as HTMLTableElement, {
onStart: ({ oldIndex, target }) => {
if (oldIndex === undefined) {
throw new Error('onStart oldIndex 不存在');
}
setTimeout(() => {
target.classList.add('sortable-drag');
}, 50);
const { tableData, fullData } = this.tableRef.getTableData();
this.dropData.oldIndex = fullData
.findIndex((item) => item[this.rowKey] === tableData[oldIndex][this.rowKey]);
this.dropData.chooseRow = tableData[oldIndex];
},
onEnd: ({ newIndex, target }) => {
target.classList.remove('sortable-drag');
if (newIndex === undefined) {
throw new Error('onEnd newIndex 不存在');
}
// TODO: 相同id处理
const { tableData, fullData } = this.tableRef.getTableData();
this.dropData.newIndex = fullData
.findIndex((item) => item[this.rowKey] === tableData[newIndex][this.rowKey]);
const originData = [...fullData];
const targetObject = fullData.splice(this.dropData.oldIndex, 1)[0];
fullData.splice(this.dropData.newIndex, 0, targetObject);
console.log(this.dropData);
// 如果请求拖拽接口失败,先在父组件里面将props的data 更新为拖拽后的数据(list)
// 然后再赋值originData, 用来diff更新数据
const emitData: ISortDropEmit = {
targetObject,
list: fullData,
originData,
oldIndex: this.dropData.oldIndex,
newIndex: this.dropData.newIndex,
};
this.$emit('drop', emitData);
},
});
}
prevScrollTop = 0
/** 滚动事件处理 */
onHandleScroll(e: IVxeTableScroll) {
if (this.loading) return;
const { clientHeight } = e.$event.target as HTMLTableElement; // vxeTable 传过来的bodyHeight 不准确, 所有用clientHeight
const { scrollTop, scrollHeight } = e;
if (this.prevScrollTop > scrollTop) { // 向上滚动
return;
}
this.prevScrollTop = scrollTop;
if (scrollTop + clientHeight >= scrollHeight - 30) {
console.log('到底了');
this.$emit('reach-bottom', e);
}
}
}
</script>
<style lang="scss" scoped>
</style>
注意点
- 分页加载: vxe table可以通过其自带的scroll事件来处理分页加载。注意的是, vxeTable 传过来的bodyHeight 不准确, 所以用滚动事件的target的clientHeight。
- 拖拽元素的新旧下标:由于vxetable渲染的时候只渲染了部分数据。所以在onEnd的时候拿到的数据可能不是真正的元素相对于真实数据的下标。所以要在fullData里面寻找下标然后做操作。这个下标最好保存起来。 提供给父组件回退的时候使用
- 保存失败回退:由于vxetable使用的是经过处理后的数据。 首先在父组件将拖拽后的数据赋值给vxetable(如果不这样做,失败回退数据就不会生效),如果服务器保存失败。 就将未拖拽前的数据赋值给vxetable(需要添加一个保存原始数据以供保存失败的时候处理。 )。例子如下
本文使用 markdown.com.cn 排版