vue3基于 Vxe Table 实现可拖拽分组 + 动态求和的高级表格


介绍

在企业级后台系统中,表格往往需要支持列拖拽分组、分组内数据求和、虚拟滚动等复杂功能。本文基于 Vue3 + TypeScript + Vxe Table + SortableJS,实现一套可拖拽分组、自动计算分组合计、支持虚拟滚动的高级表格组件,解决海量数据下的分组统计需求。

依赖安装

拖拽库SortableJS安装

javascript 复制代码
npm i sortablejs

VxeTable安装

javascript 复制代码
npm i vxe-pc-ui
npm i vxe-table

VxeTable官网:https://vxetable.cn/#/demo/list

核心功能

  1. 列头拖拽分组: 将表格列头拖拽到指定区域,自动按该列字段分组
  2. 分组内数据求和: 对指定字段自动计算分组内合计值,并渲染汇总行;
  3. 虚拟滚动: 支持大数据量表格的高性能渲染;
  4. 拖拽回退: 分组列可拖拽回表格,取消对应分组;
  5. 自适应高度:表格高度随容器自动适配,兼容窗口缩放。

代码实现

  1. 封装拖拽 Hook:useDragTable.ts
    基于 SortableJS 封装通用的拖拽逻辑,支持表格列头与分组区域的双向拖拽:
  2. 封装数据处理工具函数
    提供分组聚合、求和计算的通用工具函数,适配不同业务场景:
  3. 表格组件核心代码
    整合拖拽、分组、求和、虚拟滚动的完整组件:

细节说明

1. 拖拽逻辑核心

  • 使用 SortableJS 实现列头与分组区域的双向拖拽,通过group配置实现拖拽组关联;
  • onMove事件阻止默认拖拽行为,onEnd事件处理列配置的移动逻辑;
  • 组件卸载时销毁 Sortable 实例,避免内存泄漏。

2. 分组求和核心

  • insertGroupData函数按分组 key 将数据分组,遍历计算每组内指定字段的合计值;
  • 生成isGroupFooter标记的汇总行,通过spanMethod实现汇总行跨列展示;
  • 兼容非数字值处理,避免 NaN 异常。

3. 性能优化

  • 虚拟滚动:Vxe Table 的virtual-y-config配置,数据量 > 50 时自动启用;
  • 自适应高度:通过 ResizeObserver 监听容器尺寸变化,动态调整表格高度;
  • 深拷贝:所有数据处理均使用深拷贝,避免污染原始数据。

拖拽hooks

表格拖拽 useDragTable Hooks

javascript 复制代码
import Sortable from 'sortablejs'
export const useDragTable = (tableRef, dropAreaRef, dragColumn, ColumnConfig, DomClass = '.el-table__header-wrapper tr') => { 
  // 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(DomClass)
    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.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.splice(moved.dragIndex || 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
    }
  }
}

基于vxe Table 表头拖拽汇总组件

javascript 复制代码
<template>
  <div class="dragContainer" >
    <div class="dragContent" ref="dropAreaRef">
      <div class="dragItem" v-for="(item, index) in dragColumn" :key="index">{{ item.title }}</div>
      <div class="dragTs" v-if="dragColumn.length === 0">Drag a Column header here to group by that column</div>
    </div>
  </div>
  <div class="table-container" ref="containerRef">
    <vxe-table
      ref="tableRef"
      :data="list"
      :virtual-y-config="{enabled: true, gt: 50}"
      :column-config="{resizable: true}"
      :row-config="{isHover: true}"
      :height="tableHeight"
      :span-method="spanMethod"
      :aggregate-config="aggregateConfig"
      :show-footer="true"
      :footer-method="footerMethod"
      :render-format="{ autoMerge: false }"
      :cell-class-name="getCellClassName"
      show-overflow="tooltip"
      :tooltip-config="{
        trigger: 'cell',
        enterable: true
      }"
      border
    >
      <vxe-column 
        v-for="(item, index) in ColumnConfig"
        sortable
        :key="item.dataKey" 
        :field="item.dataKey"
        :title="item.title" 
        :min-width="item.width"
        :row-group-node="index === 0 ? true : false"
      >   
        <template #group-content="{ childList }">
          <span v-for="(item, index) in dragColumn" :key="index">{{  item['title']  }}: {{ childList?.[0][item.dataKey] }},</span>
          <span v-for="(value, key, index) in parentFields" :key="index">
            <template v-if="!!childList?.[0][value]">
              {{ key }}: {{ childList?.[0][value] || 0 }}
              {{ index < Object.keys(parentFields).length - 1 ? ', ' : '' }}
            </template>
          </span>
        </template>
      </vxe-column>
    </vxe-table>
  </div>
</template>
<script lang="tsx" setup>
import { useDragTable } from '../hooks/useDragTable'
import { cloneDeep } from 'lodash-es'

const props = defineProps({
  // 表格配置项
  ColumnConfig: {
    type: Array as PropType<any[]>,
    default: () => [] 
  },
  tableNewList: {
    type: Array,
    default: () => []
  },
  parentFields: {
    type: Object,
    default: () => ({})
  },
})

const containerRef = ref<HTMLDivElement | null>(null)
const tableHeight = ref(0)
// 表格节点
const tableRef = ref()
// 拖拽节点
const dropAreaRef = ref()
// 列表数据
const list: any = ref([])
const dragColumn: any = ref([])
const aggregateConfig = reactive({})

onMounted(async () => {
  await nextTick()
  setTableHeight()
})
const setTableHeight = () => {
  if (!containerRef.value) return
  const containerHeight = containerRef.value.clientHeight
  tableHeight.value = containerHeight
}

window.addEventListener('resize', () => {
  setTableHeight()
})

onMounted(() => {
  if (!containerRef.value) return
  const resizeObserver = new ResizeObserver(() => {
    setTableHeight()
  })
  resizeObserver.observe(containerRef.value)
})
// 确定分组
const handleRowGroup = () => {
  const $table = tableRef.value
  if ($table) {
    $table.setRowGroups('complexField')
  }
}

// 取消分组
const cancelRowGroup = () => {
  const $table = tableRef.value
  if ($table) {
    $table.clearRowGroups()
  }
}

watch(() => props.tableNewList, (newVal) => {
  if(newVal.length > 0) {
    list.value = cloneDeep(props.tableNewList)
  }
  dragColumn.value.forEach(item => {
    let insertIndex = item.dragIndex
    if (insertIndex < 0) insertIndex = 0
    else if (insertIndex > props.ColumnConfig.length) insertIndex =  props.ColumnConfig.length
    props.ColumnConfig.splice(insertIndex, 0, { ...item })
  })
  dragColumn.value = []
}, { immediate: true, deep: true })

// 监听拖拽列变化
watch(() => dragColumn.value, (newValue) => {
  console.log('dragColumn', newValue) 
  if(newValue.length > 0) {
    const tempRawList = cloneDeep(props.tableNewList)
    const oldDragList = newValue.map(item => item.dataKey)
    tempRawList.forEach((row: any) => {
      delete row.complexField
      row.complexField = oldDragList.map(field => row[field]).join('-');
    }); 
    // 分组
    handleRowGroup()   
    list.value = insertGroupData(tempRawList)
  } else {
    list.value = cloneDeep(props.tableNewList)
    cancelRowGroup()
  }
}, { immediate: true, deep: true })
const spanMethod = ({ row, column }) => {
  const $table = tableRef.value
  const firstField = props.ColumnConfig[0]!.dataKey
  if ($table && $table.isAggregateRecord(row)) {
    if (column.field === firstField) {
      return { rowspan: 1, colspan: 100  }
    }
    return { rowspan: 0, colspan: 0 }
  }
  return { rowspan: 1, colspan: 1 }
}
// 单元格样式函数:为汇总行添加专属类名
const getCellClassName = ({ row }) => {
  // 如果是汇总行,返回自定义类名
  if (row.isGroupFooter) {
    return 'group-footer-row'
  }
  return ''
}
const footerMethod = ({ columns }) => {
  const data: any = props.tableNewList
  const needSumFieldValues = Object.values(props.parentFields);
  return [
    columns.map((column, index) => {
      if (index === 0) {
        return 'SUM'
      }
      const currentColumnProp = column.field;
      if (!needSumFieldValues.includes(currentColumnProp)) {
        return '';
      }

      // 满足条件,执行原有的合计计算逻辑
      const values = data.map((item) => Number(item[currentColumnProp]));
        
      if (!values.every((value) => Number.isNaN(value))) {
        return `${values.reduce((prev, curr) => {
          const value = Number(curr);
          if (!Number.isNaN(value)) {
            return prev + value
          } else {
            return prev;
          }
        }, 0)}`;
      }
    })
  ]
}

// 对分组后的每组数据进行求和
const insertGroupData = (list) => {
  const groupMap = {}
  list.forEach(item => {
    if (!groupMap[item.complexField]) {
      groupMap[item.complexField] = []
    }
    groupMap[item.complexField].push(item)
  })
  const result: any = []
  // 遍历每个分组,计算汇总并生成汇总行
  Object.keys(groupMap).forEach(groupKey => {
    const groupItems: any = groupMap[groupKey]
    // 添加分组内的原始数据
    result.push(...groupItems)  

    // 对parentFields中的所有字段求和
    const sumResult = {}
    // 遍历parentFields的value(数据字段名),逐个求和
    Object.values(props.parentFields).forEach(field => {
      sumResult[field] = groupItems.reduce((sum, item) => {
        // 容错处理:非数字转0,避免NaN
        const value = Number(item[field]) || 0
        return sum + value
      }, 0)
    })
    // 生成分组汇总行(包含所有集装箱类型的求和结果)
    result.push({
      complexField: groupKey,
      groupName: groupKey,
      name: '【分组汇总】',
      isGroupFooter: true, // 标记为汇总行,避免被二次分组
      // 把求和结果赋值到对应字段
      ...sumResult,
      num: '合计',
    })
  })
  return result
}

// 拖拽表格
useDragTable(tableRef, dropAreaRef, dragColumn, props.ColumnConfig, '.vxe-table--header-inner-wrapper tr')
</script> 
<style lang="scss" scoped>
.dragContainer {
  border: 1px solid #f0f2f5;
  background: #f0f2f5;
  height: 50px;
  overflow: hidden;
  box-sizing: border-box;
  .dragContent {
    display: flex;
    flex-wrap: wrap;
    height: 100%;
    align-items: center;
    padding: 0 10px;  
  }
  .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(.vxe-footer--row .vxe-footer--column) {
  background: rgba(0, 90, 174, 0.2);  
}
:deep(.group-footer-row) {
  background: rgba(0, 90, 174, 0.1);
}
.table-container {
  height: calc(100vh - 300px);
  :deep(.vxe-table--column) {
    font-weight: normal;
    color: #636369;
  }
  :deep(.col--filter) {
    .vxe-cell--filter {
      float: right;
      vertical-align: middle;
      margin-top: 3px;
    }
    .vxe-filter--btn::before {
      content: '' !important;
      display: inline-block;
      width: 16px;
      height: 16px;
      background: url('@/assets/svgs/doc/filter.svg') no-repeat center center;
      background-size: 100% 100%;
      vertical-align: middle;
    }
    .vxe-cell--title {
      color: #636369;
    }
  }
  :deep(.is--filter-active) {
    .vxe-cell--filter {
      float: right;
      vertical-align: middle;
      margin-top: 3px;
    }
    .vxe-filter--btn::before {
      content: '' !important;
      display: inline-block;
      width: 16px; 
      height: 16px;
      background: url('@/assets/svgs/doc/filter-checked.svg') no-repeat center center;
      background-size: 100% 100%; // 适配图片尺寸
      vertical-align: middle;
    }
    .vxe-cell--title {
      color: #005aae;
    }
  }
}
</style>

组件引用

javascript 复制代码
  <VxeDragTable 
    :ColumnConfig="ColumnConfig"
    :tableNewList="tableNewList"
    :parentFields="parentFields"
  />
const ColumnConfig = ref([
  { dataKey: 'hs40', title: '40HS', width: 80, dragIndex: 1 },
  { dataKey: 'ht20', title: '20HT', width: 80, dragIndex: 2 },
  { dataKey: 'ht40', title: '40HT', width: 80, dragIndex: 3 },
  { dataKey: 'pl20', title: '20PL', width: 80, dragIndex: 4 },
]) // 表格配置
const tableNewList = ref([]) // 数据
const parentFields = ref({ // 需要求和字段	
  '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',
  '20HS': 'hs20',
  '40HS': 'hs40',
  '20HT': 'ht20',
  '40HT': 'ht40',
  '20PL': 'pl20',
  '40PL': 'pl40',
  '20HC': 'hc20',
  '20RH': 'rh20',
  '40HR': 'hr40',
  '45GP': 'gp45',
  '45HC': 'hc45'
})

可视化拖拽数据大屏项目地址

react版本:https://gitee.com/qlsgr/DataReport/

vue版本:https://gitee.com/belief-team/report

相关推荐
还是大剑师兰特2 小时前
Vue3 + Element Plus 日期选择器:开始 / 结束时间,结束时间不超过今天
前端·javascript·vue.js
不会写DN2 小时前
Js常用数组处理
开发语言·javascript·ecmascript
还是大剑师兰特2 小时前
数组中有两个数据,将其变成字符串
开发语言·javascript·vue.js
Saga Two2 小时前
Vue实现核心原理
前端·javascript·vue.js
技术钱2 小时前
vue3实现时间根据系统时区转换对应的时间
javascript·vue.js
殷忆枫2 小时前
基于STM32的ML307R连接Onenet平台
服务器·前端·javascript
Java 码农2 小时前
vue cli 环境搭建
前端·javascript·vue.js
酉鬼女又兒2 小时前
零基础入门前端JavaScript Object 对象完全指南:从基础到进阶(可用于备赛蓝桥杯Web应用开发赛道)
开发语言·前端·javascript·职场和发展·蓝桥杯
RPGMZ2 小时前
RPGMakerMZ游戏引擎 地图角色顶部显示称号
javascript·游戏引擎·rpgmz·rpgmakermz