一:需求描述
1.在列表数据中,双击进入回单详情页,行操作的签收按钮点击后进入回单签收页。
2.回单详情页中table展示数据,form展示订单信息,其中form汇总中的数值与table中的数据要有对应关系(实发数对应发货数,实收对应收货数,异常数为非正品签收之外的所有数量相加)。
3.回单签收页中,在回单详情页展示的基础上,增加字段可编辑功能,编辑的同时,table总计行与form汇总的数据随之动态渲染。
PS:先附上我最后实现的效果图
二:思考如何实现?
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;
}
}
效果如下:
使用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 合并方法。说实话,到这里,我终于被折服了,这不纯纯解决了我需求中所有的问题吗!点我看实例
先看看我实现的效果吧:
好了,上代码:
<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 };
}
}
}
}
由于涉及到具体业务逻辑和信息安全,所以代码只有关键片段。