


最近在做报表的时候,需要拖拽对应的表头,根据拖拽的表头数据进行汇总, 执行来回拖拽,拖拽借用了Sortable.js这个插件来实现的。汇总通过后台查询的一维数组,拖拽一个表头根据对应的表头字段将一维数组进行分组成树形结构数据
Sortable.js使用
安装
javascript
npm install sortablejs --save
引入
javascript
import Sortable from 'sortablejs'
跨区域拖拽
- html结构一个列表一个表格
javascript
// 拖拽区域进行分组
<div class="dragContainer" >
<div class="dragContent" ref="dropAreaRef">
<div class="dragItem" v-for="(item, index) in dragColumn" :key="index">{{ item.label }}</div>
<div class="dragTs" v-if="dragColumn.length === 0">Drag a Column header here to group by that column</div>
</div>
</div>
// 表格
<el-table
ref="tableRef"
:data="list"
highlight-current-row
row-key="id"
:span-method="handleSpanMethod"
:summary-method="getSummaries"
:show-summary="isShowSummary"
border
>
<el-table-column v-for="(item, index) in ColumnConfig" :key="index" :prop="item.prop" :label="item.label" :min-Width="item.width" show-overflow-tooltip>
<template #default="scope">
<!-- 父节点 -->
<div v-if="scope.row.children && scope.row.children.length > 0">
<span v-for="(item, index) in dragColumn" :key="index">{{ item['label'] }}: {{scope.row[item.prop]}},</span>
<span v-for="(value, key, index) in parentFields">
{{ isSumShow.includes(value) ? 'SUM' : '' }}
{{ key }}: {{ scope.row[value] || 0 }}
{{ index === Object.keys(parentFields).length - 1 ? '': ', '}}
</span>
</div>
</template>
</el-table-column>
</el-table>
初始化 Sortable (group 配置)
javascript
// 初始化拖拽
const initDrag = async () => {
// 先销毁旧实例
destroySortableInstances()
// 等待 DOM 完全渲染
await nextTick()
// 表格表头拖拽
if (!tableRef.value || !tableRef.value.$el) return
const headerTr = tableRef.value.$el.querySelector('.el-table__header-wrapper tr')
if (!headerTr || !dropAreaRef.value) return
tableSortable = Sortable.create(headerTr, {
group: { name: 'table-columns', pull: true, put: true, },
animation: 150,
draggable: 'th',
onMove: (evt: any) => {
TableToDiv = evt
return false
},
onEnd: (evt: any) => {
if(TableToDiv.from === TableToDiv.to) return
const [moved] = ColumnConfig.value.splice(evt.oldIndex, 1)
dragColumn.value.splice(evt.newIndex, 0, moved)
TableToDiv = {}
}
})
areaSortable = Sortable.create(dropAreaRef.value, {
group: { name: 'table-columns', pull: true, put: true },
animation: 150,
draggable: '.dragItem', // 明确单个拖拽单元
onMove: (evt: any) => {
divToTable = evt
return false
},
onEnd: (evt: any) => {
if(divToTable.from === divToTable.to) return
const [moved] = dragColumn.value.splice(evt.oldIndex, 1)
ColumnConfig.value.splice(evt.newIndex, 0, moved)
divToTable = {}
}
})
}
sortable group配置name名字一致可以实现跨列表拖拽,pull 接受 true/false/clone,是否允许元素从当前列表拉出,put: true 允许接收同组其它列表的数组,在这里我遇到了个问题我两个区域相互拖拽的时候 pull 我设置了 true导致拖拽的时候标签元素也给拖拽走了,设置clone也不生效,后面再onMove的时候记录一份从哪里拖拽到哪里
常用核心配置
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| animation | Number | 0 | 拖拽动画时长(毫秒),推荐 100-300,提升视觉体验 |
| ghostClass | String | '' | 拖拽过程中占位元素的类名,用于自定义占位样式 |
| chosenClass | String | '' | 被选中(开始拖拽)的元素类名,用于自定义选中样式 |
| dragClass | String | '' | 拖拽过程中元素自身的类名,用于自定义拖拽元素样式 |
| group | Object/String | null | 跨列表拖拽配置:字符串表示组名;对象支持 name/pull/put |
| dataIdAttr | String | 'data-id' | 元素唯一标识属性名,配合 toArray() 方法使用 |
| disabled | Boolean | false | 是否禁用拖拽功能(可动态修改:sortable.option('disabled', true)) |
| handle | String | null | 拖拽触发区域(选择器),如 .drag-handle,仅点击该区域可拖拽元素 |
| onStart | Function | null | 拖拽开始时触发的回调,参数 evt(包含 item、oldIndex 等) |
| onEnd | Function | null | 拖拽结束时触发的回调(最常用),参数 evt(包含新旧索引、元素等) |
| onAdd | Function | null | 元素从其他列表添加到当前列表时触发 |
| onRemove | Function | null | 元素从当前列表移除到其他列表时触发 |
| onUpdate | Function | null | 同一列表内元素排序发生变化时触发 |
完整代码
hooks useDragTable.ts
javascript
export const useDragTable = (tableRef, dropAreaRef, dragColumn, ColumnConfig) => {
// Sortable实例
let tableSortable: Sortable | null = null
let areaSortable: Sortable | null = null
// 用于记录拖拽的哪里到哪里
let divToTable: any = {}
let TableToDiv: any = {}
// 初始化拖拽
const initDrag = async () => {
// 先销毁旧实例
destroySortableInstances()
// 等待 DOM 完全渲染
await nextTick()
// 表格表头拖拽
if (!tableRef.value || !tableRef.value.$el) return
const headerTr = tableRef.value.$el.querySelector('.el-table__header-wrapper tr')
if (!headerTr || !dropAreaRef.value) return
tableSortable = Sortable.create(headerTr, {
group: { name: 'table-columns', pull: true, put: true, },
animation: 150,
draggable: 'th',
onMove: (evt: any) => {
TableToDiv = evt
return false
},
onEnd: (evt: any) => {
if(TableToDiv.from === TableToDiv.to) return
const [moved] = ColumnConfig.value.splice(evt.oldIndex, 1)
dragColumn.value.splice(evt.newIndex, 0, moved)
TableToDiv = {}
}
})
areaSortable = Sortable.create(dropAreaRef.value, {
group: { name: 'table-columns', pull: true, put: true },
animation: 150,
draggable: '.dragItem', // 明确单个拖拽单元
onMove: (evt: any) => {
divToTable = evt
return false
},
onEnd: (evt: any) => {
if(divToTable.from === divToTable.to) return
const [moved] = dragColumn.value.splice(evt.oldIndex, 1)
ColumnConfig.value.splice(evt.newIndex, 0, moved)
divToTable = {}
}
})
}
onMounted(async () => {
await nextTick()
initDrag()
})
// 组件卸载时销毁实例
onUnmounted(() => {
destroySortableInstances()
})
// 销毁 Sortable 实例(避免冲突和内存泄漏)
const destroySortableInstances = () => {
if (tableSortable) {
tableSortable.destroy()
tableSortable = null
}
if (areaSortable) {
areaSortable.destroy()
areaSortable = null
}
}
}
多字段联合分组聚合 + 指定字段求和 + 生成新的子节点id
javascript
/**
* 多字段联合分组聚合 + 指定字段求和 + 子节点id拼接数组顺序
* @param {Array} rawData - 原始数据数组
* @param {Array} groupFields - 联合分组字段数组(如 ['billNo', 'route'])
* @param {Array|String} sumFields - 需要求和的字段(单个字段传字符串,多个传数组,如 'teu' 或 ['container20', 'container40'])
* @returns {Array} 聚合后的树形结构数据(父节点含正确求和值,子节点id拼接数组顺序索引)
*/
export const aggregateDataByField = (rawData, groupFields, sumFields: string[] = []) => {
// 1. 入参校验
if (!rawData || rawData.length === 0) {
ElMessage.warning('原始数据不能为空')
return []
}
if (!groupFields || !Array.isArray(groupFields) || groupFields.length === 0) {
ElMessage.warning('联合分组字段必须为非空数组')
return []
}
// 格式化求和字段为数组(兼容单个字符串传入)
const sumFieldArr = Array.isArray(sumFields) ? sumFields : (sumFields ? [sumFields] : [])
// 2. 多字段联合分组:生成唯一分组key(拼接所有分组字段值)
const groupMap = {}
rawData.forEach(item => {
// 生成联合分组key:如 billNo='B/L-123' + route='中国-美国' → 'B/L-123|中国-美国'(用特殊字符分隔,避免字段值冲突)
const groupKey = groupFields.map(field => {
return item[field] || 'default_value' // 字段不存在时用默认值兜底
}).join('|') // 可自定义分隔符(确保字段值中不含该字符,如 |、# 等)
// 初始化分组
if (!groupMap[groupKey]) {
groupMap[groupKey] = [];
}
// 推入原始数据(深拷贝避免污染原数据)
groupMap[groupKey].push({ ...item })
});
// 3. 构建聚合结果:父节点(含正确求和) + 子节点id拼接顺序索引
const result = []
Object.keys(groupMap).forEach(groupKey => {
const groupData = groupMap[groupKey]
if (groupData.length === 0) return
// 3.1 以分组第一条数据为基础构建父节点(保留所有原始字段)
const parentNode = { ...groupData[0] }
// 3.2 修复求和逻辑:确保父节点正确累加所有子节点(分组内所有数据)的指定字段
sumFieldArr.forEach(sumField => {
let total = 0
// 遍历分组内所有数据(而非仅过滤后),确保求和完整
groupData.forEach(item => {
// 兼容字符串数字、纯数字,非数字值强制转为0,避免NaN异常
const fieldValue = item[sumField]
let numValue = 0
if (typeof fieldValue === 'string' || typeof fieldValue === 'number') {
numValue = Number(fieldValue) || 0
}
total += numValue
});
// 覆盖父节点的该字段值为求和结果
parentNode[sumField] = total
})
// 3.3 处理子节点:所有原始数据存入children + id拼接数组顺序索引(从0开始,可改为从1开始)
const childrenWithNewId = groupData.map((item, index) => {
// 深拷贝子节点数据,避免修改原始数据
const childNode = { ...item }
// 给id字段拼接数组顺序索引(格式:原id + 索引,可自定义拼接符)
if (childNode.id !== undefined && childNode.id !== null) {
// 支持id为字符串/数字类型,拼接后转为字符串
childNode.id = `${childNode.id}_${index}` // 索引从0开始,如需从1开始改为 index + 1
} else {
// 若子节点无id字段,默认生成带索引的id
childNode.id = `default_id_${groupKey}_${index}`
}
return childNode
});
// 子节点添加最后一条数据用来汇总
const sumMap = {}
sumFieldArr.forEach(sumField => {
sumMap[sumField] = parentNode[sumField]
})
childrenWithNewId.push({ ...sumMap, id: parentNode.id + '_total', rowType: 'Sum' })
// 给父节点赋值children(带拼接后id的子节点数组)
parentNode.children = childrenWithNewId
// 3.4 推入结果数组
result.push(parentNode)
})
return result
};
跨区域表格拖拽组件
javascript
<template>
<div class="dragContainer" >
<div class="dragContent" ref="dropAreaRef">
<div class="dragItem" v-for="(item, index) in dragColumn" :key="index">{{ item.label }}</div>
<div class="dragTs" v-if="dragColumn.length === 0">Drag a Column header here to group by that column</div>
</div>
</div>
<el-table
ref="tableRef"
:data="list"
highlight-current-row
row-key="id"
:span-method="handleSpanMethod"
:summary-method="getSummaries"
:show-summary="isShowSummary"
border
>
<el-table-column v-for="(item, index) in ColumnConfig" :key="index" :prop="item.prop" :label="item.label" :min-Width="item.width" show-overflow-tooltip>
<template #default="scope">
<!-- 父节点 -->
<div v-if="scope.row.children && scope.row.children.length > 0">
<span v-for="(item, index) in dragColumn" :key="index">{{ item['label'] }}: {{scope.row[item.prop]}},</span>
<span v-for="(value, key, index) in parentFields">
{{ isSumShow.includes(value) ? 'SUM' : '' }}
{{ key }}: {{ scope.row[value] || 0 }}
{{ index === Object.keys(parentFields).length - 1 ? '': ', '}}
</span>
</div>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" setup>
import { useDragTable, aggregateDataByField } from '@/hooks/web/useDragTable'
const { t, locale } = useI18n() // 国际化
// 表格节点
const tableRef = ref()
// 拖拽列节点
const dropAreaRef = ref()
// 列表数据
const list = ref([])
const dragColumn: any = ref([])
const ColumnConfig: any = ref([
{ prop: 'patx', label: t('document.statisSingleVoyage.pAtx'), width: 140 }, // 预计离泊时间
{ prop: 'blNo', label: t('document.statisSingleVoyage.blNo'), width: 180 }, // 提单号
{ prop: 'voyageEx', label: t('document.statisSingleVoyage.voyageEx'), width: 100 }, // 航线
{ prop: 'vesselCode', label: t('document.statisSingleVoyage.vesselCode'), width: 100 }, // 船名
{ prop: 'laneCodeEx', label: t('document.statisSingleVoyage.laneCodeEx'), width: 80 }, // 航次
{ prop: 'vslvoy', label: t('document.statisSingleVoyage.vslvoy'), width: 180 }, // 前/后程船名航次
{ prop: 'shipperLocalName', label: t('document.statisSingleVoyage.shipperLocalName'), width: 180 }, // 订舱人 Local Name
{ prop: 'shipperEnName', label: t('document.statisSingleVoyage.shipperEnName'), width: 180 }, // 订舱人 EN Name
{ prop: 'porCode', label: t('document.statisSingleVoyage.porCode'), width: 100 }, // 收货地
{ prop: 'polCode', label: t('document.statisSingleVoyage.polCode'), width: 100 }, // 装港
{ prop: 'podCode', label: t('document.statisSingleVoyage.podCode'), width: 100 }, // 卸港
{ prop: 'delCode', label: t('document.statisSingleVoyage.delCode'), width: 100 }, // 目的地
{ prop: 'podTerminal', label: t('document.statisSingleVoyage.podTerminal'), width: 120 }, // 卸货港码头
{ prop: 'cntrNo', label: t('document.statisSingleVoyage.cntrNo'), width: 140 }, // 箱号
{ prop: 'cntrOwner', label: t('document.statisSingleVoyage.cntrOwner'), width: 100 }, // 箱属
{ prop: 'cntrStatus', label: t('document.statisSingleVoyage.cntrStatus'), width: 100 }, // 箱态
{ prop: 'payment', label: t('document.statisSingleVoyage.payment'), width: 100 }, // 付款方式
{ prop: 'stuffLocEx', label: t('document.statisSingleVoyage.stuffLocEx'), width: 100 }, // 装箱点
{ prop: 'cargoType', label: t('document.statisSingleVoyage.cargoType'), width: 100 }, // 货类
{ prop: 'size20', label: "20'", width: 80 },
{ prop: 'size40', label: "40'", width: 80 },
{ prop: 'size20H', label: "20'H", width: 80 },
{ prop: 'size40H', label: "40'H", width: 80 },
{ prop: 'size45', label: "45'", width: 80 },
{ prop: 'shippingTerm', label: t('document.statisSingleVoyage.shippingTerm'), width: 100 }, // 运输条款
{ prop: 'agreementNo', label: 'SCNO', width: 180 },
{ prop: 'commodityEn', label: t('document.statisSingleVoyage.productName'), width: 140 }, // 品名
{ prop: 'hsCode', label: 'HSCODE', width: 100 },
{ prop: 'shipper', label: t('document.statisSingleVoyage.shipper'), width: 180 }, // 发货人
{ prop: 'consignee', label: t('document.statisSingleVoyage.consignee'), width: 180 }, // 收货人
{ prop: 'notify', label: t('document.statisSingleVoyage.notify'), width: 180 }, // 通知人
{ prop: 'topAgent', label: t('document.statisSingleVoyage.topAgent'), width: 180 }, // 一级代理
{ prop: 'teu', label: 'TEU', width: 80 },
{ prop: 'rf20', label: '20RF', width: 80 },
{ prop: 'rf40', label: '40RF', width: 80 },
{ prop: 'rh40', label: '40RH', width: 80 },
{ prop: 'ot20', label: '20OT', width: 80 },
{ prop: 'ot40', label: '40OT', width: 80 },
{ prop: 'fr20', label: '20FR', width: 80 },
{ prop: 'fr40', label: '40FR', width: 80 },
{ prop: 'gp20', label: '20GP', width: 80 },
{ prop: 'gp40', label: '40GP', width: 80 },
{ prop: 'hc40', label: '40HC', width: 80 },
{ prop: 'tk20', label: '20TK', width: 80 },
{ prop: 'tk40', label: '40TK', width: 80 },
{ prop: 'hs20', label: '20HS', width: 80 },
{ prop: 'hs40', label: '40HS', width: 80 },
{ prop: 'ht20', label: '20HT', width: 80 },
{ prop: 'ht40', label: '40HT', width: 80 },
{ prop: 'hc20', label: '20HC', width: 80 },
{ prop: 'rh20', label: '20RH', width: 80 },
{ prop: 'hr40', label: '40HR', width: 80 },
{ prop: 'pl20', label: '20PL', width: 80 },
{ prop: 'pl40', label: '40PL', width: 80 },
{ prop: 'gp45', label: '45GP', width: 80 },
{ prop: 'hc45', label: '45HC', width: 80 },
{ prop: 'grossWeight', label: t('document.statisSingleVoyage.grossWeight'), width: 100 }, // 货物毛重
{ prop: 'siNotifyEmails', label: 'EMAIL', width: 180 },
])
const isSumShow = ['size20', 'size40', 'size20H', 'size40H', 'size45']
// 父节点展示的字段
const parentFields = {
'20': 'size20',
'40': 'size40',
'20H': 'size20H',
'40H': 'size40H',
'45H': 'size45',
'TEU': 'teu',
'20RF': 'rf20',
'40RF': 'rf40',
'40RH': 'rh40',
'20OT': 'ot20',
'40OT': 'ot40',
'20FR': 'fr20',
'40FR': 'fr40',
'20GP': 'gp20',
'40GP': 'gp40',
'40HC': 'hc40',
'20TK': 'tk20',
'40TK': 'tk40'
}
// 需要求和的字段
const sumFields = ['size20', 'size40', 'size20H', 'size40H', 'size45', 'teu', 'rf20', 'rf40', 'rh40', 'ot20', 'ot40', 'fr20', 'fr40', 'gp20', 'gp40', 'hc40', 'tk20', 'tk40']
// 是否汇总
const isShowSummary = computed(() => {
return dragColumn.value.length > 0
})
// 接受父组件传递过来的list
const tableNewList: any = inject('tableListOne')
watch(() => tableNewList.value, (newVal) => {
if(newVal.length > 0) {
list.value = tableNewList.value
}
dragColumn.value = []
}, { immediate: true, deep: true })
// 监听拖拽列变化
watch(() => dragColumn.value, (newValue) => {
if(newValue.length > 0) {
const oldDragList = newValue.map(item => item.prop)
list.value = aggregateDataByField(tableNewList.value, oldDragList, sumFields)
} else {
list.value = tableNewList.value
}
}, { immediate: true, deep: true })
// 单元格合并
const handleSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
const tableColumnCount = 58
if (row.children && row.children.length > 0) {
// 父节点第一列:横向合并所有列(colspan=总列数),纵向不合并(rowspan=1)
if (columnIndex === 0) {
return {
rowspan: 1,
colspan: tableColumnCount
};
}
// 父节点其他列:设为 rowspan=0 + colspan=0(隐藏该单元格,实现整行合并)
else {
return {
rowspan: 0,
colspan: 0
}
}
}
// 子节点(level≥1):不进行任何合并,返回 { rowspan: 1, colspan: 1 }(可省略,默认值)
return {
rowspan: 1,
colspan: 1
};
}
// 单元格求和
const getSummaries = (param: { columns: any[]; data: any[] }) => {
const { columns, data } = param;
const sums: (string | VNode)[] = [];
const needSumFieldValues = Object.values(parentFields);
console.log('param', param)
// 总量合计
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = h('div', { style: { textDecoration: 'underline' } }, [
'SUM',
]);
return;
}
const currentColumnProp = column.property;
if (!needSumFieldValues.includes(currentColumnProp)) {
sums[index] = '';
return;
}
// 满足条件,执行原有的合计计算逻辑
const values = data.map((item) => Number(item[currentColumnProp]));
if (!values.every((value) => Number.isNaN(value))) {
sums[index] = `${values.reduce((prev, curr) => {
const value = Number(curr);
if (!Number.isNaN(value)) {
return prev + value
} else {
return prev;
}
}, 0)}`;
}
})
return sums;
}
defineExpose({
ColumnConfig: ColumnConfig.value
})
// 拖拽表格
useDragTable(tableRef, dropAreaRef, dragColumn, ColumnConfig)
</script>
<style lang="scss" scoped>
.dragContainer {
border: 1px solid #f0f2f5;
background: #f0f2f5;
padding: 10px;
.dragContent {
display: flex;
flex-wrap: wrap;
}
.dragItem {
box-sizing: border-box;
font-size: 12px;
border: 1px solid #999;
padding: 5px;
border-radius: 2px;
margin-right: 10px;
}
.dragTs {
color: #999;
font-size: 12px;
box-sizing: border-box;
}
}
:deep(.el-table__row--level-0 td .cell) {
display: flex;
align-items: center;
}
:deep(.el-table__footer-wrapper .el-table__footer tr td) {
padding: 0 !important;
}
</style>