Vue2 实现table中带有合并行操作的表尾合计行

1,598 阅读6分钟

一:需求描述

1.在列表数据中,双击进入回单详情页,行操作的签收按钮点击后进入回单签收页。

2.回单详情页中table展示数据,form展示订单信息,其中form汇总中的数值与table中的数据要有对应关系(实发数对应发货数,实收对应收货数,异常数为非正品签收之外的所有数量相加)。

3.回单签收页中,在回单详情页展示的基础上,增加字段可编辑功能,编辑的同时,table总计行与form汇总的数据随之动态渲染。

PS:先附上我最后实现的效果图

image.png

image.png

二:思考如何实现?

1.首先我在接到这个需求的时候,先考虑整体的布局,应该如何拆分?(UI设计以及组件式开发)

整个弹窗的body,先分为一个左右结构,左边是一个Upload(本章不涉及)
其次右面,又分为上下结构,上面是一个form表单,下面是一个table表格
分析完毕,我们就可以,将整个body大致分为三部分,即三个组件

那么既然分为三个组件,其中的form组件和table组件又有数据关联,那么一定会涉及到组件之间的传参。
相信大家对于组件间的通信,一定非常熟悉了,但是提示一下这里有坑的,那就是数据污染,后面详细说!

2.对于form表单的组件,相对比较容易,汇总中的数据,需要手动计算,其余直接取请求到的数据就可以了。(不是重点,简单叙述一下就可以了)

3.table表格组件,相对比较复杂,总共涉及几个要点

a)  多级表头 以及 自定义表头(给文字溢出的表头添加Tooltip)
b)  自由控制可编辑的表格内容,以及编辑时触发的一系列计算逻辑
c)  表尾添加合计行,并且涉及到合计行(有坑,重点讲述)

三、Element UI

1.多级表头 以及 自定义表头(给文字溢出的表头添加Tooltip)

首先,多级表头,无需多言,文档写的里面很清楚。只需要在 el-table-column 里面嵌套 el-table-column,就可以实现多级表头。

其次,为了更好的体验,在表头中我们使用自定义表头,实现当表头内容溢出时展示Tooltip的效果。

代码如下:

<el-table-column label="附件缺失签收" prop="fittingshortSignQty" show-overflow-tooltip align="center">
    <template slot="header">
        <div @mouseover="onMouseOver('fittingshortSignQty')" class="tooltip">
            <el-tooltip
                :disabled="fittingshortSignQty"
                content="附件缺失签收"
                placement="top"
            >
                <div class="tooltip"><span ref="fittingshortSignQty">附件缺...</span></div>
            </el-tooltip>
        </div>
      </template>
</el-table-column>
onMouseOver (refName) {
    const parentWidth = this.$refs[refName].parentNode.offsetWidth;
    const contentWidth = this.$refs[refName].offsetWidth;
    // 判断是否开启tooltip功能
    if (contentWidth > parentWidth) {
        this[refName] = false;
    } else {
        this[refName] = true;
    }
}

效果如下:

image.png 使用header插槽实现自定义表头,给el-tooltip子元素添加ref,然后给Tooltip容器添加鼠标移入事件onMouseOver,传入对应子元素的ref。

在onMouseOver方法中,获取当前节点的宽度以及父节点的宽度,用于判断是否显示tooltip。

注意,要定义好控制显隐的变量fittingshortSignQty(代码中没有写)。

2.自由控制可编辑的表格内容,以及编辑时触发的一系列计算逻辑

这里的组件,有两种模式,一种为可编辑状态,另一种为不可编辑状态。

可编辑状态下我们使用input,不可编辑状态我们使用div,由一个变量结合v-if去控制两者的显隐,来做到可编辑和不可编辑模式。

编辑时又要触发一系列的计算逻辑,就是给input标签添加一个input事件,并将具体的计算逻辑写入(业务逻辑就不详细介绍了)。

在上面代码结构式的基础上进行优化:

<el-table-column label="附件缺失签收" prop="fittingshortSignQty" show-overflow-tooltip align="center">
    <template slot="header">
        <div @mouseover="onMouseOver('fittingshortSignQty')" class="tooltip">
            <el-tooltip
                :disabled="fittingshortSignQty"
                content="附件缺失签收"
                placement="top"
            >
            <div class="tooltip"><span ref="fittingshortSignQty">附件缺...</span></div>
            </el-tooltip>
        </div>
    </template>
    <template slot-scope="scope">
        <el-input
            v-model.trim="scope.row.fittingshortSignQty"
            v-if="signTableFieldIsEdit.fittingshortSignQty"
            @input="computedNumber(scope.row,'fittingshortSignQty')"
            class="customTableColumnInput"
        />
        <div class="notEdit" v-else>
             {{ scope.row.fittingshortSignQty }}
        </div>
    </template>
</el-table-column>

3.表尾添加合计行,并且涉及到合计行(有坑,重点讲述)

1.方法

对总计行进行合并的实现,我开始查了很多资料,接下来我说下为什么我没有采用。

(1)首先是Element-ui官网提供的

将show-summary设置为true就会在表格尾部展示合计行。 也可以定义自己的合计逻辑,使用summary-method并传入一个方法,返回一个数组,这个数组中的各项就会显示在合计行的各列中。

通过给table传入span-method方法可以实现合并行或列,方法的参数是一个对象,里面包含当前行row、当前列column、当前行号rowIndex、当前列号columnIndex四个属性。

官网提供的这两种方法,分别需要定义两个不同的方法,但是两者不能同时生效。

(2)其次就是,操作DOM,获取到当前的table组件,并插入行。

这个方法的复杂度和代码量,不用多说,一定是很大的。然后,创建出来的合计行,不能与原组件匹配,比如手动拖动行宽度等。而且,已经什么年代了?都用Vue了,还操作什么DOM啊!立马放弃!

(3)接下来就是我采用的方法 - 数据驱动DOM

我们知道Vue中的数据是响应式的,既然是响应式的,那么我们开发过程中只需要关注数据本身,不需要关心数据是如何渲染到视图的。这就是,数据驱动DOM,Vue 最独特的特性之一。

在MVVM模型中,当数据发生变化时,虚拟DOM计算变更,diff算法会计算出最小的变更量,这个过程在Vue中叫patch,通过核心计算渲染时指挥修改改变过的节点,没有变化的节点不需要再次渲染,以便以最小的代价去操作真实DOM,最后更新试图。

2.实现

所以,说了这么多,我们最后只需要做两件事,就可以完成该需求。

一、修改数据,添加合计行

我们在接收到请求到的数据后,对数据进行格式化,在数组的最尾部,插入合计行。

在el-table中,数据是根据prop进行匹配到的,所以我们要先获取,table中的prop属性。代码如下:

        /**
         * @description: 计算合计 加入到table最后一行
         * @param {Array} tableDataList 列表数据
         * @return {tableDataList} 加入合计后的数据
         */
        pushLastTableList (tableDataList) {
            const pushTableData = JSON.parse(JSON.stringify(tableDataList);
            // 动态获取table中的prop属性(判断是否存在子元素,如果存在则进行双重循环)
            this.$refs.B2BSignInTable.$children.forEach(item => {
                if (item.label && item.label !== undefined && item.$children.length === 0) {
                    this.SignInTablePropObj[item.prop] = 0;
                    this.SignInTablePropArr.push(item.prop);
                } else if (item.label && item.label !== undefined && item.$children.length !== 0) {
                    item.$children.forEach((childrenItem) => {
                        this.SignInTablePropObj[childrenItem.prop] = 0;
                        this.SignInTablePropArr.push(childrenItem.prop);
                    });
                }
            });
            // 赋值总计行数据并插入到表单数据
            pushTableData.forEach(item => {
                const realDeliverCountTotal = (+item.realDeliverCount || 0);
                const takeGoodsCountTotal = (+item.goodSignQty || 0) + (+item.machinedmgSignQty || 0) + (+item.packingdmgSignQty || 0) + (+item.fittingshortSignQty || 0);
                /* eslint-disable */
                const rejectGoodsCountTotal = (+item.goodRejectQty || 0) + (+item.packingdmgRejectQty || 0) + (+item.machinedmgRejectQty || 0) + (+item.fittingshortRejectQty || 0) + (+item.performRejectQty || 0);
                /* eslint-enable */
                const shortGoodsCountTotal = (+item.shortQty || 0);
                this.SignInTablePropObj[this.SignInTablePropArr[0]] = '合计:';
                this.SignInTablePropObj[this.SignInTablePropArr[1]] += realDeliverCountTotal; // 发货总计
                this.SignInTablePropObj[this.SignInTablePropArr[2]] += takeGoodsCountTotal; // 收货总计
                this.SignInTablePropObj[this.SignInTablePropArr[3]] += rejectGoodsCountTotal; // 拒收总计
                this.SignInTablePropObj[this.SignInTablePropArr[4]] += shortGoodsCountTotal; // 短少总计
                this.SignInTablePropObj[this.SignInTablePropArr[5]] = ''; // 备注总计
            });
            pushTableData.push(this.SignInTablePropObj);
            return pushTableData;
        },

这里要特别注意:

(1)我们对数据进行格式化的时候,需要浅拷贝出一份数据,然后对浅拷贝的数据进行格式化,否则会影响原本的数据,使得其他用到改数据的组件出现问题,这就是之前说到的一个坑 - 数据污染

(2)由于此处使用的为,多级表格,我们在获取table中的prop属性时,需要判断是否存在子元素,如果存在则进行双重循环

(3)我们先定义了一个对象,用于push到数据中,用于插入合计行。又定义了一个专门存放prop的数组,以便将数值正确的赋值到每一个对应的prod属性中。此处我们只复制了6个数据,因为我们的原型图中,合并后的单元格,只有6个。

二、合并单元格

这里我们就可以直接使用Element-ui官网提供的方法了

        /**
         * @description: 合并单元格
         * @param:row 当前行
         * @param:column 当前列
         * @param:rowIndex 当前行号
         * @param:columnIndex 当前列号
         * @return {*} 合并的单元格格式
         */
        arraySpanMethod ({ row, column, rowIndex, columnIndex }) {
            // 判断是否为最后一行
            if (rowIndex === this.signInTable.length - 1) {
                if (columnIndex === 0) {
                    return [1, 2];
                } else if (columnIndex === 1) {
                    return [1, 1];
                } else if (columnIndex === 2) {
                    return [1, 4];
                } else if (columnIndex === 3) {
                    return [1, 5];
                } else if (columnIndex === 4 || columnIndex === 5) {
                    return [1, 1];
                } else {
                    return [0, 0];
                }
            }
        },

此时,我们要注意两个问题:

(1)合并单元格时,不需要合并的,要return [1, 1],并且最后要return [0, 0]以清除多余格式。最后,我们的el-table中的每个el-table-column不能设置宽度,以上三点全部满足,才不会重现错乱。这一点,我差点被搞死,开发时各种问题,之后一点点调试出来的......都是泪~~~

(2)这里还没有结束,因为我发现,我做出来的合计行,竟然也可以编辑......最后发现,table中的设定全部根据prop去匹配的,所以,我们之前的可编辑控制,还要加一个条件。

scope.$index !== signInTable.length - 1

<el-table-column label="附件缺失签收" prop="fittingshortSignQty" show-overflow-tooltip align="center">
    <template slot="header">
        <div @mouseover="onMouseOver('fittingshortSignQty')" class="tooltip">
            <el-tooltip
                :disabled="fittingshortSignQty"
                content="附件缺失签收"
                placement="top"
            >
            <div class="tooltip"><span ref="fittingshortSignQty">附件缺...</span></div>
            </el-tooltip>
        </div>
    </template>
    <template slot-scope="scope">
        <el-input
            v-model.trim="scope.row.fittingshortSignQty"
            v-if="signTableFieldIsEdit.fittingshortSignQty && scope.$index !== signInTable.length - 1"
            @input="computedNumber(scope.row,'fittingshortSignQty')"
            class="customTableColumnInput"
        />
        <div class="notEdit" v-else>
             {{ scope.row.fittingshortSignQty }}
        </div>
    </template>
</el-table-column>

前面我们介绍了如何使用element-ui实现这种复杂的table,介绍过程中,仅仅讲述了某些功能点,及其实现的思路和关键代码段,如果有遇到问题,可以评论区留言,大家集思广益~

四、Vxe-table

是不是以为就要结束了?不!上面的实现方法,代码量也是不小,而且又臭又长,看起来就头疼。所以,作为一个好学的小菜鸡,我决定寻找其他的实现方法 - Vxe-table。(我用的是Vue2版本的)

之前我们用element-ui时,由于api不支持,所以我们只能使用自定义表头、数据驱动DOM的方式曲线救国。但是,对于这种复杂的table,我强烈推荐大家使用Vxe-table。里面提供了丰富的api,可以任意组合使用,支持绝大多数场景需要,同时适配Vue2和Vue3。但这也是把双刃剑,在为我们提供了强大的api的同时,还提升了上手难度(个人认为比el-table要稍稍复杂些)。面对这个问题,我相信大家一定会选择去克服困难去学习它(悄悄告诉大家,其实不难的,我从第一次接触到完成需求只用了10个小时)。

好了,闲话少说,来看看我是如何使用Vxe-table实现的吧。

在这里,我使用的是Vxe-table中的高级表格。首先,对于表头的溢出显示toolTip,Vxe-table是提供了对应的配置的,并且Vxe-table将toolTip分为了表头、表尾以及表格内容三种配置,可以说相当细节了。分别是show-header-overflow,show-footer-overflow,show-overflow。详细介绍请看文档点我看实例

其次,对于编辑表格,并且可以禁用某些条件的编辑表格,Vxe-table也提供了对应的api,设置 edit-config 的 activeMethod 方法判断单元格是否禁用。

然后,对于,表尾插入数据,他又一次提供了强大的api,并且支持实时更新!!!通过设置footer-method,实现插入表尾数据,并且通过手动调用 updateFooter 函数,可以实现实时更新数据。而且这里提供了两种实例,一种是返回二维数组方式,另一种是由方法计算方式

最后,对于合并行或列,他又双叒叕(yòu shuāng ruò zhuó)提供了非常强大的api。不仅仅可以简单的合并单元格,还可以单独对表尾合并行或列,通过 merge-footer-items 临时合并,或者自定义 footer-span-method 合并方法。说实话,到这里,我终于被折服了,这不纯纯解决了我需求中所有的问题吗!点我看实例

先看看我实现的效果吧:

image.png

image.png

好了,上代码:

<template>
    <vxe-grid
        ref="B2CSignInTable"
        v-bind="B2CSignInTable"
        :footer-span-method="footerRowspanMethod"
        align="center"
        show-overflow="title"
        show-header-overflow="title"
        show-footer-overflow="title"
    >

        <template #itemCode_edit="{ row }">
            <vxe-input v-model="row.itemCode"></vxe-input>
        </template>

        <template #itemName_edit="{ row }">
            <vxe-input v-model="row.itemName"></vxe-input>
        </template>

        <template #realDeliverCount_edit="{ row }">
            <vxe-input v-model="row.realDeliverCount"></vxe-input>
        </template>

        <template #goodSignQty_edit="{ row }">
            <vxe-input
                v-model="row.goodSignQty"
                @input="computedNumberFromGoodSignQty(row, 'goodSignQty')"
            ></vxe-input>
        </template>

        <template #packingdmgSignQty_edit="{ row }">
            <vxe-input
                v-model="row.packingdmgSignQty"
                @input="computedNumber(row, 'packingdmgSignQty')"
            ></vxe-input>
        </template>

        <template #goodRejectQty_edit="{ row }">
            <vxe-input
                v-model="row.goodRejectQty"
                @input="computedNumberFromGoodRejectQty(row, 'goodRejectQty')"
            ></vxe-input>
        </template>

        <template #packingdmgRejectQty_edit="{ row }">
            <vxe-input v-model="row.packingdmgRejectQty"
                @input="computedNumber(row, 'packingdmgRejectQty')"
            ></vxe-input>
        </template>

        <template #machinedmgRejectQty_edit="{ row }">
            <vxe-input
                v-model="row.machinedmgRejectQty"
                @input="computedNumber(row, 'machinedmgRejectQty')"
            ></vxe-input>
        </template>

        <template #shortQty_edit="{ row }">
            <vxe-input
                v-model="row.shortQty"
                @input="computedNumber(row, 'shortQty')"
            ></vxe-input>
        </template>

        <template #signRemark_edit="{ row }">
            <vxe-input v-model="row.signRemark"></vxe-input>
        </template>
    </vxe-grid>
</template>
    data () {
        return {
            B2CSignInTable: {
                border: true,
                stripe: true,
                resizable: true,
                showFooter: true,
                editConfig: {
                    trigger: 'click',
                    mode: 'cell',
                    showIcon: false,
                    activeMethod: this.activeRowMethod
                },
                footerMethod: this.footerMethod,
                footerSpanMethod: this.footerSpanMethod,
                columns: [
                    { field: 'itemCode', title: '商品编码', editRender: {}, slots: { edit: 'itemCode_edit' } },
                    { field: 'itemName', title: '商品名称', editRender: {}, slots: { edit: 'itemName_edit' } },
                    { field: 'realDeliverCount', title: '发货', editRender: {}, slots: { edit: 'realDeliverCount_edit' } },
                    {
                        title: '收货',
                        children: [
                            { field: 'goodSignQty', title: '正品签收', editRender: {}, slots: { edit: 'goodSignQty_edit' } },
                            { field: 'packingdmgSignQty', title: '箱损签收', editRender: {}, slots: { edit: 'packingdmgSignQty_edit' } }
                        ]
                    },
                    {
                        title: '拒收',
                        children: [
                            { field: 'goodRejectQty', title: '正品拒收', editRender: {}, slots: { edit: 'goodRejectQty_edit' } },
                            { field: 'packingdmgRejectQty', title: '箱损拒收', editRender: {}, slots: { edit: 'packingdmgRejectQty_edit' } },
                            { field: 'machinedmgRejectQty', title: '机损拒收', editRender: {}, slots: { edit: 'machinedmgRejectQty_edit' } }
                        ]
                    },
                    { field: 'shortQty', title: '短少', editRender: {}, slots: { edit: 'shortQty_edit' } },
                    { field: 'signRemark', title: '备注', editRender: {}, slots: { edit: 'signRemark_edit' } }
                ],
                data: []
            },
            footerData: [[]],
            disableList: []
        };
    },
    methods: {
        // 计算表尾合计
        computedSumUpNumber (rawData) {
            let realDeliverCount = 0;
            let takeGoodsSumCount = 0;
            let shortGoodsSumCount = 0;
            let rejectGoodsSumCount = 0;
            rawData.forEach(item => {
                const realDeliverCountToatl = (+item.realDeliverCount || 0);
                const takeGoodsCountTotal = (+item.goodSignQty || 0) + (+item.packingdmgSignQty || 0);
                const rejectGoodsCountTotal = (+item.goodRejectQty || 0) + (+item.packingdmgRejectQty || 0) + (+item.machinedmgRejectQty || 0);
                const shortGoodsCountTotal = (+item.shortQty || 0);
                realDeliverCount += realDeliverCountToatl;
                takeGoodsSumCount += takeGoodsCountTotal;
                rejectGoodsSumCount += rejectGoodsCountTotal;
                shortGoodsSumCount += shortGoodsCountTotal;
            });
            this.footerData = [
                ['合计', '', realDeliverCount, takeGoodsSumCount, '', rejectGoodsSumCount, '', '', shortGoodsSumCount, '']
            ];
            // 在值发生改变时更新表尾合计
            if (this.$refs.B2CSignInTable) {
                this.$refs.B2CSignInTable.updateFooter();
            }
        },
        // 可编辑逻辑
        activeRowMethod ({ row, rowIndex, column, columnIndex }) {
            return this.disableList.includes(column.field);
        },
        // 表尾数据(接收二维数据)
        footerMethod () {
            return this.footerData;
        },
        // 表尾合并
        footerRowspanMethod ({ _rowIndex, _columnIndex }) {
            if (_rowIndex === 0) {
                if (_columnIndex === 0) {
                    return { rowspan: 1, colspan: 2 };
                } else if (_columnIndex === 1) {
                    return { rowspan: 0, colspan: 0 };
                } else if (_columnIndex === 3) {
                    return { rowspan: 1, colspan: 2 };
                } else if (_columnIndex === 4) {
                    return { rowspan: 0, colspan: 0 };
                } else if (_columnIndex === 5) {
                    return { rowspan: 1, colspan: 3 };
                } else if (_columnIndex === 6) {
                    return { rowspan: 0, colspan: 0 };
                } else if (_columnIndex === 7) {
                    return { rowspan: 0, colspan: 0 };
                }
            }
        }
    }

由于涉及到具体业务逻辑和信息安全,所以代码只有关键片段。