基于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 排版

相关推荐
星就前端叭44 分钟前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234521 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成1 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript
jwensh2 小时前
【Jenkins】Declarative和Scripted两种脚本模式有什么具体的区别
运维·前端·jenkins
关你西红柿子2 小时前
小程序app封装公用顶部筛选区uv-drop-down
前端·javascript·vue.js·小程序·uv
益达是我2 小时前
【Chrome】浏览器提示警告Chrome is moving towards a new experience
前端·chrome
济南小草根2 小时前
把一个Vue项目的页面打包后再另一个项目中使用
前端·javascript·vue.js
聪小陈2 小时前
圣诞节:记一次掘友让我感动的时刻
前端·程序员
LUwantAC2 小时前
CSS(一):选择器
前端·css
Web阿成2 小时前
5.学习webpack配置 babel基本配置
前端·学习·webpack