vue3 + element plus 实现表格列头、行的添加及拖动换位
拖动换位使用sortablejs实现
安装:
npm install sortablejs --save
思考
- 列表头部放到一个数组里,使用循环来渲染。
- 一个数组保存额外添加的列项的数据,点击按钮显示下拉数据,选择项后弹出弹窗可选择需要添加的位置,默认选择添加到最后一行。
- 点击列表头部的
+
号,在当前合适的位置显示列项选择下拉框,点击直接在当前列后添加选择的列,不必在弹添加的位置选择框。 - 可添加的列项只能添加一次,选择后会移除。
- 添加行就直接在数据中添加一个数据对象。
功能实现代码
<template><div class="content"><h1>sortablejs实现表格拖拽完整版</h1><div class="table-header"><h2 class="table-title">表格列管理</h2><div class="add-column-btn" @click="showAddColumnDropdown($event, -1)"><i class="el-icon-plus">+</i><span>添加列</span></div><!-- 添加列下拉菜单 --><div v-if="showColumnDropdown" class="column-dropdown" style="visibility: hidden;"><div class="dropdown-title">选择要添加的列类型</div><ul class="dropdown-list"><li v-for="item in availableColumns" :key="item.type" @click.stop="selectColumnType(item)">{{ item.label }}</li></ul></div><!-- 选择列位置弹窗 --><el-dialogv-model="showPositionDialog"title="选择添加位置"width="30%":close-on-click-modal="false":show-close="true"><div class="position-select-container"><el-select v-model="selectedPosition" placeholder="请选择添加位置" style="width: 100%"><el-optionv-for="(column, index) in tableList":key="`after-${index}`":label="`在 ${column.label} 之后`":value="`after-${index}`"></el-option><el-option label="添加到最后" value="end"></el-option></el-select></div><template #footer><span class="dialog-footer"><el-button @click="showPositionDialog = false">取消</el-button><el-button type="primary" @click="confirmAddColumn">确认</el-button></span></template></el-dialog></div><div class="table"><el-table ref="boxRef" :data="tableData" style="width: 100%" row-key="id" class="draggable-table" :fit="true"border><!-- 添加操作列,不参与拖拽 --><el-table-column width="50" label="" class-name="operation-column" fixed="left"><template #header><div class="non-draggable-header">#</div></template><template #default="scope"><div class="add-row-icon" @click="showAddRowOptions(scope.row, scope.$index)"><i class="el-icon-plus">+</i></div></template></el-table-column><el-table-column v-for="(item, index) in tableList" :key="item.type" :prop="item.prop" show-overflow-tooltipmin-width="120" :width="null"><template #header><div class="custom-header"><span>{{ item.label }}</span><i v-if="availableColumns.length > 0"class="el-icon-plus header-add-icon"@click.stop="showAddColumnDropdown($event, index)">+</i></div></template></el-table-column></el-table></div><div><el-button type="primary" @click="addRow">添加一行</el-button></div></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import Sortable from 'sortablejs';
import { ElMessageBox } from 'element-plus';//sortable实例
const rowSortable: any = ref(null)
const columnSortable: any = ref(null)// 可用列数组
const availableColumns = reactive([{ type: 'text', label: '文本列', prop: 'text' },{ type: 'number', label: '数字列', prop: 'number' },{ type: 'date', label: '日期列', prop: 'date' },{ type: 'select', label: '选择列', prop: 'select' }
]);// 控制添加列下拉菜单和位置选择弹窗的显示
const showColumnDropdown = ref(false);
const showPositionDialog = ref(false);
const selectedPosition = ref('end');
const selectedColumnType = ref<any>(null);// 存储当前选中的列索引
const selectedColumnIndex = ref(-1);// 存储点击事件的位置信息
const clickPosition = ref({ x: 0, y: 0, target: null as HTMLElement | null });// 设置下拉菜单位置
const setDropdownPosition = () => {if (!showColumnDropdown.value) return;// 使用nextTick确保在DOM更新后设置位置nextTick(() => {const dropdown = document.querySelector('.column-dropdown') as HTMLElement;if (dropdown && clickPosition.value.target) {// 获取点击元素的位置和尺寸const targetRect = clickPosition.value.target.getBoundingClientRect();// 计算下拉菜单的位置let dropdownLeft = targetRect.left;let dropdownTop = targetRect.bottom + 5; // 添加5px的间距// 获取窗口尺寸和下拉菜单尺寸const windowWidth = window.innerWidth;const windowHeight = window.innerHeight;const dropdownWidth = 200; // 下拉菜单宽度const dropdownHeight = dropdown.offsetHeight || 150; // 如果无法获取高度,使用估计值// 检查是否会超出右边界if (dropdownLeft + dropdownWidth > windowWidth) {dropdownLeft = windowWidth - dropdownWidth - 5; // 留5px边距}// 检查是否会超出下边界if (dropdownTop + dropdownHeight > windowHeight) {// 如果下方空间不足,则显示在点击元素上方dropdownTop = targetRect.top - dropdownHeight - 5;}// 设置下拉菜单的位置dropdown.style.position = 'fixed';dropdown.style.left = `${dropdownLeft}px`;dropdown.style.top = `${dropdownTop}px`;dropdown.style.right = 'auto'; // 清除right属性dropdown.style.visibility = 'visible'; // 确保可见}});
};// 监听窗口大小变化,重新计算下拉菜单位置
onMounted(() => {window.addEventListener('resize', setDropdownPosition);
});onUnmounted(() => {window.removeEventListener('resize', setDropdownPosition);
});// 显示添加列下拉菜单
const showAddColumnDropdown = (event: MouseEvent, index: number) => {event.stopPropagation();selectedColumnIndex.value = index;// 存储点击位置信息clickPosition.value = {x: event.clientX,y: event.clientY,target: event.target as HTMLElement};// 如果下拉菜单已经显示,则隐藏它if (showColumnDropdown.value) {showColumnDropdown.value = false;document.removeEventListener('click', closeDropdown);return;}// 显示下拉菜单并设置位置showColumnDropdown.value = true;setDropdownPosition();// 点击其他地方关闭下拉菜单nextTick(() => {document.addEventListener('click', closeDropdown);});
};// 关闭下拉菜单
const closeDropdown = (event: MouseEvent) => {const dropdowns = document.querySelectorAll('.column-dropdown');let shouldClose = true;// 检查点击是否在任何下拉菜单内dropdowns.forEach(dropdown => {if (dropdown.contains(event.target as Node)) {shouldClose = false;}});if (shouldClose) {// 先隐藏下拉菜单,再设置状态const dropdown = document.querySelector('.column-dropdown') as HTMLElement;if (dropdown) {dropdown.style.visibility = 'hidden';}showColumnDropdown.value = false;document.removeEventListener('click', closeDropdown);// 重置点击位置信息clickPosition.value = { x: 0, y: 0, target: null };}
};// 选择列类型
const selectColumnType = (column: any) => {selectedColumnType.value = column;showColumnDropdown.value = false;// 如果有选中的列索引,默认选择在该列之后if (selectedColumnIndex.value !== -1) {selectedPosition.value = `after-${selectedColumnIndex.value}`;} else {// 否则默认选择添加到最后selectedPosition.value = 'end';}// 如果表格为空,直接添加(不显示位置选择弹窗)if (tableList.length === 0) {confirmAddColumn();return;}// 判断是否是顶部"+"按钮点击if (selectedColumnIndex.value === -1) {// 顶部"+"按钮点击,显示位置选择弹窗showPositionDialog.value = true;} else {// 列表头"+"按钮点击,直接在当前列后添加confirmAddColumn();}document.removeEventListener('click', closeDropdown);
};// 确认添加列
const confirmAddColumn = () => {if (!selectedColumnType.value) return;const column = selectedColumnType.value;const newColumn = {type: column.type,label: column.label,prop: column.prop};// 确定插入位置let insertIndex = tableList.length; // 默认添加到最后if (selectedColumnIndex.value !== -1) {// 如果是列表头"+"按钮点击,直接在当前列后添加insertIndex = selectedColumnIndex.value + 1; // 在选中列之后插入} else if (selectedPosition.value && selectedPosition.value !== 'end') {// 如果是通过位置选择弹窗选择的位置const [_, indexStr] = selectedPosition.value.split('-');insertIndex = parseInt(indexStr) + 1; // 在选中列之后插入}// 在指定位置插入新列tableList.splice(insertIndex, 0, newColumn);// 为现有数据添加新属性tableData.forEach(row => {// 根据列类型设置默认值if (column.type === 'text') {row[column.prop] = `文本${Math.floor(Math.random() * 100)}`;} else if (column.type === 'number') {row[column.prop] = Math.floor(Math.random() * 1000);} else if (column.type === 'date') {const randomDate = new Date();randomDate.setDate(randomDate.getDate() - Math.floor(Math.random() * 30));row[column.prop] = randomDate.toISOString().split('T')[0];} else if (column.type === 'select') {const options = ['选项A', '选项B', '选项C'];row[column.prop] = options[Math.floor(Math.random() * options.length)];}});// 从可用列数组中移除该列const index = availableColumns.findIndex(item => item.prop === column.prop);if (index !== -1) {availableColumns.splice(index, 1);}// 关闭弹窗并重置状态showPositionDialog.value = false;selectedColumnType.value = null;selectedPosition.value = 'end'; // 默认选择添加到最后selectedColumnIndex.value = -1; // 重置选中的列索引// 重新初始化排序nextTick(() => {initColumnSortable();});
};// 显示添加行选项
const showAddRowOptions = (row: any, index: number) => {ElMessageBox.confirm('请选择添加位置','添加行',{confirmButtonText: '在此行之前',cancelButtonText: '在此行之后',type: 'info',showClose: true,distinguishCancelAndClose: true,}).then(() => {// 在当前行之前添加addRowAt(index);}).catch((action) => {if (action === 'cancel') {// 在当前行之后添加addRowAt(index + 1);}});
};// 在指定位置添加行
const addRowAt = (index: number) => {const newId = String(Date.now()); // 使用时间戳作为唯一IDconst newRow = {date: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,name: `User${newId.slice(-4)}`,id: newId,address: `Address ${newId.slice(-4)}, Some City`,};tableData.splice(index, 0, newRow);// 添加新行后重新初始化排序nextTick(() => {initRowSortable();});
};const tableList = reactive([{type: '1',label: '姓名',prop: 'name',},{type: '2',label: '日期',prop: 'date',},{type: '3',label: '地址',prop: 'address',},
])const tableData = reactive([{date: '2016-05-03',name: 'Tom1',id: '1',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-02',name: 'Tom2',id: '2',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-04',name: 'Tom3',id: '3',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-01',name: 'Tom4',id: '4',address: 'No. 189, Grove St, Los Angeles',},
])// 添加一行数据
const addRow = () => {const newId = String(tableData.length + 1);tableData.push({date: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,name: `User${newId}`,id: newId,address: `Address ${newId}, Some City`,});// 添加新行后重新初始化排序nextTick(() => {initRowSortable();initColumnSortable();});
}//获取容器dom元素
const boxRef: any = ref(null)// 初始化行排序功能
const initRowSortable = () => {const tbody = boxRef.value.$el.querySelector('.el-table__body-wrapper tbody');if (!tbody) return;if (rowSortable.value) {rowSortable.value.destroy();}rowSortable.value = Sortable.create(tbody, {animation: 150,ghostClass: 'sortable-ghost',onEnd: ({ newIndex, oldIndex }: { newIndex: number; oldIndex: number }) => {if (newIndex === oldIndex) return;const currRow = tableData.splice(oldIndex, 1)[0];tableData.splice(newIndex, 0, currRow);}});
}// 初始化列排序功能
const initColumnSortable = () => {const headerRow = boxRef.value.$el.querySelector('.el-table__header-wrapper tr');if (!headerRow) return;if (columnSortable.value) {columnSortable.value.destroy();}// 获取所有表头单元格const headerCells = headerRow.querySelectorAll('th');// 确保第一个单元格不可拖动if (headerCells.length > 0) {headerCells[0].classList.add('operation-column-header');headerCells[0].setAttribute('data-no-sortable', 'true');}columnSortable.value = Sortable.create(headerRow, {animation: 150,ghostClass: 'sortable-ghost',handle: '.cell',filter: '.operation-column-header, .non-draggable-header', // 添加过滤器,防止操作列被拖动preventOnFilter: true, // 阻止在过滤的元素上触发拖动onStart: function (evt: any) {// 如果是第一列,取消拖动if (evt.oldIndex === 0) {this.options.disabled = true;setTimeout(() => {this.options.disabled = false;}, 0);return false;}},onMove: function (evt: any) {// 阻止拖动到第一列位置const targetIndex = evt.related ? Array.from(evt.related.parentNode.children).indexOf(evt.related) : 0;if (targetIndex === 0) {return false;}},onEnd: ({ newIndex, oldIndex }: { newIndex: number; oldIndex: number }) => {if (newIndex === oldIndex || oldIndex === 0 || newIndex === 0) return;// 由于第一列是操作列,需要调整索引const adjustedNewIndex = newIndex > 0 ? newIndex - 1 : 0;const adjustedOldIndex = oldIndex > 0 ? oldIndex - 1 : 0;// 只有当操作的不是第一列时才进行排序if (oldIndex > 0) {const currColumn = tableList.splice(adjustedOldIndex, 1)[0];tableList.splice(adjustedNewIndex, 0, currColumn);}}});
}// 监听数据变化,重新初始化排序
watch([tableData, tableList], () => {nextTick(() => {initRowSortable();initColumnSortable();});
});onMounted(() => {nextTick(() => {initRowSortable();initColumnSortable();});
});onUnmounted(() => {if (rowSortable.value) {rowSortable.value.destroy();}if (columnSortable.value) {columnSortable.value.destroy();}
});
</script><style lang="scss">
.content {min-height: calc(100vh - 150px);.table-header {display: flex;justify-content: flex-start; /* 改为flex-start,让元素从左侧开始排列 */align-items: center;margin-bottom: 15px;position: relative;.table-title {font-size: 16px;font-weight: bold;margin-right: auto; /* 让标题占据左侧空间 */}.add-column-btn {display: flex;align-items: center;cursor: pointer;padding: 6px 16px;background-color: #ffffff;border: 1px solid #dcdfe6;border-radius: 4px;color: #606266;transition: all 0.3s;height: 28px;margin-left: auto; /* 确保按钮在右侧 */&:hover {color: #409EFF;border-color: #c6e2ff;background-color: #ecf5ff;}i {margin-right: 4px;font-weight: bold;line-height: 1;}span {font-size: 13px;line-height: 1;}}.column-dropdown {position: fixed; /* 改为fixed定位,便于根据点击位置定位 */width: 200px;background-color: white;border: 1px solid #ebeef5;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);z-index: 1000; /* 提高z-index确保显示在最上层 */.dropdown-title {padding: 10px 15px;font-size: 14px;color: #909399;border-bottom: 1px solid #ebeef5;}.dropdown-list {list-style: none;padding: 0;margin: 0;li {padding: 10px 15px;cursor: pointer;transition: background-color 0.3s;&:hover {background-color: #f5f7fa;}}}}}.table {margin-bottom: 20px;}
}/* 操作列样式 */
.add-row-icon {cursor: pointer;// border: 1px solid #eee;i {font-size: 26px;color: #ccc;font-style: normal;}&:hover {i {color: #67c23a;}}
}/* 不可拖动的表头样式 */
.non-draggable-header {cursor: default !important;
}/* 确保操作列不显示拖动指示器 */
.el-table__header-wrapper th.operation-column-header,
.el-table__header-wrapper th:first-child {cursor: default !important;&:hover::after {display: none !important;content: none !important;}.cell {cursor: default !important;}
}/* 操作列样式 */
.operation-column {.cell {cursor: default !important;}
}.draggable-table {position: relative;.el-table {width: 100% !important;table-layout: fixed !important;.el-table__cell {padding: 8px 12px !important;height: auto !important;line-height: 1.5;.cell {overflow: hidden;text-overflow: ellipsis;white-space: normal !important;word-break: break-word;}}}/* 表头样式 */.el-table__header-wrapper {th {position: relative;cursor: move;user-select: none;background-color: #f5f7fa;.cell {position: relative;}.custom-header {display: flex;align-items: center;justify-content: space-between;position: relative;span {flex: 1;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}.header-add-icon {cursor: pointer;color: #1890ff;font-size: 16px;margin-right: 5px;opacity: 0.8;&:hover {opacity: 1;}}}&:hover {background-color: #e6f1fc;}}}
}/* 表格行样式 */
.el-table__row {position: relative;cursor: move;transition: all 0.2s;td:first-child {position: relative;}&:hover {background-color: #f5f7fa;td:first-child::before {content: "⋮⋮";position: absolute;left: -15px;top: 50%;transform: translateY(-50%) rotate(90deg);color: #409EFF;font-size: 14px;opacity: 0.8;z-index: 1;}}
}.sortable-ghost {background-color: #e6f1fc !important;opacity: 0.8;
}.el-table__body-wrapper {overflow-x: hidden !important;
}
</style>
效果:
注:
这是借助插件生成的代码,需要自己的思路。
表头第一列不能拖动,其他列也不能拖到第一列来换位。