基于Sortablejs表格行拖拽

基于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 这个库, 但是这个库在拖拽完成后会闪烁一下。 翻了下源代码

github.com/WakuwakuP/e...

会在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 排版

相关推荐
崔庆才丨静觅5 小时前
稳定好用的 ADSL 拨号代理,就这家了!
前端
江湖有缘5 小时前
Docker部署music-tag-web音乐标签编辑器
前端·docker·编辑器
恋猫de小郭6 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端