可选、可拖拽、树状,要是各自实现单个功能的话不难,难就难在组合的使用。
组合使用会出现的问题:
- eltable的树状加个type="selection"选中全部子节点,父节点不会自动选中;反过来,选中父节点也不会吧全部子节点选中
- 全选只会选到一级的节点;
- 使用sortablejs选中一个item任意拖拽到别的位置,但不松手再放回原位时,会被sortablejs认为你已经拖动了的致命插件bug。
- 不同产品有不同的需求,拖拽时需要按实际需求实现逻辑。 话不多说,先来看看效果
实现效果如下:
原始结构
js
{
id:1,
parentId:null,
children:[
{
id:4,
parentId:1,
children:[
{
id:6,
parentId:4,
children:[],
}
],
},
{
id:5,
parentId:1,
children:[],
}
],
},
{
id:2,
parentId:null,
children:[],
}
{
id:3
parentId:null,
children:[],
}
项目需求:
- 同层级拖拽
- 最外层(1,2,3之间拖拽):不调用接口,前端排序
- 非最外层:
- 向上拖: 不调用接口,前端排序
- 向下拖: (4向5下面拖拽)把拖动位置(5)当成parent,调用接口把被拖动的item(4)加入到拖动位置(5)的children(即向后端传入当前拖动行和拖动后属于他的父id,跟后端协商处理)
- 非同层级拖拽
- 向上拖:(4向1上面拖)把拖动位置(1)的parent(null)当成被拖动位置(4)的parent,调用接口
- 向下拖:(6向5下面拖)把拖动位置(6)当成被拖动位置(5)的parent,调用接口
实现思路
普通的选择框和树状组件elementui官网有案例,实现起来很简单,注意设置:row-key和:tree-props,然后实现@select-all和@select的逻辑,完成全选和单选的功能。
拖拽我们使用了sortablejs,和很多插件的使用思路差不多,先创建一个dom,然后通过插件的api去创建一个实例,最后调用插件的一些函数去实现你的逻辑。
sortablejs详细配置:www.sortablejs.com/options.htm...
部分重要功能完整代码
由于我是对eltable做了个二次封装,也做了比较多注释,显得代码比较长。如果时间紧迫,急需解决问题,这里我给几个将重要的函数标识出来,其他可以无视:
- 在mounted处判断是否开启可拖拽的模式(317行)
- draggableHandler()用来实现拖拽组件(324行)
- 处理上面提到的问题3 ,即松手不放拖拽后再放回原位,被sortablejs认为被拖拽的问题(注意2个参数,分别是currentIndex 和currentTop )。处理的思路是,每次点击一个item准备拖拽时,都记录他当前这个item的高度top,如果这个高度在拖拽前后没有改变,则认为他没有被拖拽,在onEnd的时候直接return出去(308、339、344、367行 ),这样可以避免插件的bug导致一些误操作。要是我们只是选中一个item,不拖动到其他位置,即在不触发onMove的时候,只需要判断oldIndex和newIndex是否一致即可。(367行)
- 根据需求写拖动的逻辑(355行)
- 单选(532行selectFun)
- 全选(660行selectAllFun)
js
<template>
<div class="baseTable">
<!-- 如果需要树状显示,需要添加tree-props,必须添加row-key,el-table会根据树row-key展开 -->
<!--
******columnData格式******
columnData: [
{
prop: "date", 设置数据参数
label: "日期", 设置数据名
width: "180", 设置表格宽度
canPullDown:true, 树状表格,可以下拉展开的列,需要确定数据有isExpand字段,
如果没有前端自行调用递归添加:参考账套组管理initExpand()
//a标签文本,传入一个方法
click:(val) => {
//do something
},
setIcon:"set", icon图标
align:"left", 内容对齐方式,默认center,表头对齐固定为center
//如果有一些自定义的内容,可以传入一个函数slot,如
slot: (val) => {
//这里的val是改行对应的prop内容
//可以保存一些this的指向,再调用当前methods的方法,比如:
return _this.formatDate(val);
},
注意:slot和click尽量不要执行太复杂的js,由于一些操作无法避免eltable重新渲染,数据量大且操作复杂的时候会比较消耗性能,有复杂计算的请后端直接返回数据!
},
]
******tableData格式******
tableData: [
{
date: "2023-8-11",
},
]
******tableOption格式******
tableoption里的按钮会返回一个handleButton(methods,row)方法到父组件,
tableOption: {
label: "操作",
width: "200", option列的宽度
options: [
{
label: "编辑", 按钮名称
type: "text", 按钮类型
methods: "edit", 按钮对应的方法
hasPermi:['assets:assetsList:remove'] 按钮权限
可以传入一个show方法,做特定条件才显示按钮(非权限)
show: (val) => {
//这里的val是该行的数据
return val.name == "啊三" ? true : false;
},
},
{
label: "删除",
type: "text",
methods: "delete",
hasPermi:['assets:assetsList:remove']
},
],
}
******组件******
<tableView
ref="tableView"
v-if="refresh" 强制更新组件
:complexities="true" true树状表格,false普通表格
:tableData="tableData" 表格数据
:columnData="columnData" 表头信息
:needSelection="true" 默认为false,即不需要复选框
:loading="loading" 加载动画
:rowKey="'accountsId'" 行key,当draggable为true时和树状表格必须填写
:draggable="true" 是否可拖拽
:tableOption="tableOption" 操作列信息
@handleButton="handleButton" 操作列按钮,返回该行数据
@iconClick="iconClick" 点击icon返回改行数据
@selectChange="selectChange" 获取复选框中选中的内容(普通表格complexities=false)
@getSortableData="getSortableData" 获取拖拽的行的始末位置信息,并可以返回一个设置好的rowkey
@updateTableDate="updateTableDate" 当不调用接口时,又对数据进行了拖拽可以调用这个改变树组的结构(仅前端展示,刷新后会还原数据)
>
</tableView>
获取复选框中选中的内容有2种情况,但是都是通过调用getSelection这个方法获取的
------------普通表格:通过selectChange(val)监听勾选数据,会返回勾选的数据给父组件val
------------树状表格:直接调用refs.xx.getSelection()返回勾选了的数据
------------如果树状有校验规则,需要通过selectChange监听,然后调用refs.xx.getSelection()获取数据
-->
<el-table
ref="tableView"
border
class="drop"
style="width: 100%"
v-loading="loading"
:height="height"
:max-height="maxHeight"
:data="tableData"
:tree-props="{ children: 'children' }"
:row-key="rowKey"
:default-expand-all="defaultExpandAll"
@select-all="selectAllFun"
@select="selectFun"
>
<el-table-column
v-if="needSelection"
type="selection"
width="40"
header-align="center"
>
<!-- :selectable="() => false" -->
</el-table-column>
<el-table-column
v-if="needIndex"
type="index"
width="50"
align="center"
header-align="center"
class-name="allowDrag"
>
<template slot="header">
<span>序号</span>
</template>
</el-table-column>
<template v-for="(item, index) in columnData">
<!-- 处理第canPullDown下拉树 -->
<template v-if="item.canPullDown">
<el-table-column
:key="index"
:prop="item.prop"
:label="item.label"
:width="item.width"
:min-width="item.minWidth"
:align="item.align ? item.align : 'center'"
header-align="center"
>
<template slot-scope="scope">
<a v-if="item.click" @click="item.click">{{
scope.row[item.prop]
}}</a>
<span v-else>{{ scope.row[item.prop] }}</span>
<i
style="color: #235bda"
class="el-icon-caret-right"
v-show="
!scope.row.isExpand &&
scope.row.children &&
scope.row.children.length > 0
"
@click="expandFun(scope.row)"
></i>
<i
style="color: #235bda"
class="el-icon-caret-bottom"
v-show="
scope.row.isExpand &&
scope.row.children &&
scope.row.children.length > 0
"
@click="expandFun(scope.row)"
></i>
<svg-icon
v-if="item.setIcon"
style="position: absolute; right: 5px; height: 23px"
:icon-class="item.setIcon"
@click.stop="iconClick(scope.row)"
class="setSvg"
></svg-icon>
</template>
</el-table-column>
</template>
<template v-else>
<el-table-column
:key="index"
:prop="item.prop"
:label="item.label"
:width="item.width"
:min-width="item.minWidth"
:align="item.align ? item.align : 'center'"
type=""
header-align="center"
>
<template slot-scope="scope">
<!-- 处理有slot、click数据的情况,有click的是a标签 -->
<template>
<span>{{ scope.row[item.prop] }}</span>
</template>
<!-- 处理Icon -->
<svg-icon
v-if="item.setIcon"
style="position: absolute; right: 5px; height: 23px"
:icon-class="item.setIcon"
@click.stop="iconClick(scope.row)"
class="setSvg"
></svg-icon>
</template>
</el-table-column>
</template>
</template>
<!-- 操作列 -->
<el-table-column
v-if="tableOption && tableOption.label"
:width="tableOption.width"
:label="tableOption.label"
fixed="right"
:align="tableOption.align ? tableOption.align : 'center'"
class-name="small-padding fixed-width"
header-align="center"
>
<template slot-scope="scope">
<el-button
v-for="(item, index) in tableOption.options"
:key="index"
:type="item.type || 'text'"
:icon="item.icon"
:disabled="
typeof item.disabled == 'function'
? item.disabled(scope.row)
: true
? item.disabled
: false
"
:v-hasPermi="item.hasPermi"
v-show="(item.show && item.show(scope.row)) || !item.show"
@click="handleButton(item.methods, scope.row)"
size="mini"
>
{{ item.label }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import Sortable from "sortablejs";
import { treeToArray } from "@/utils";
export default {
props: {
//表头/行
columnData: {
type: Array,
default: () => [],
},
//表格数据
tableData: {
type: Array,
default: () => [],
},
height: {
type: Number || String,
default: null,
},
maxHeight: {
type: Number || String,
default: null,
},
//需要选择器
needSelection: {
type: Boolean,
default: false,
},
//需要排序器
needIndex: {
type: Boolean,
default: false,
},
//操作列
tableOption: {
type: Object,
default: () => {},
},
rowKey: {
type: String,
default: "id",
},
//树表格默认展开全部
defaultExpandAll: {
type: Boolean,
default: true,
},
//如果要实现拖拽,必须要设置唯一值rowkey
draggable: {
type: Boolean,
default: false,
},
//默认为普通checkbox选项表格,如果为true表示可下拉的数据
complexities: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
//记录当前拖动行的高度
currentTop: null,
//记录当前拖动行的数据
currentIndex: null,
isAllSelect: false,
};
},
watch: {
currentIndex: {
handler(newVal, oldVal) {
if (newVal !== oldVal) {
//每次鼠标点击的时候如果选择的行变了,currentTop清0,拖动的时候再赋值
this.currentTop = null;
}
},
},
},
mounted() {
if (this.draggable) {
this.draggableHandler();
}
},
methods: {
draggableHandler() {
const el = this.$refs.tableView.$el.querySelectorAll(
".el-table__body-wrapper > table > tbody"
)[0];
const _this = this;
Sortable.create(el, {
disabled: false, // 是否开启拖拽
ghostClass: "sortable-ghost", //拖拽样式
animation: 150, // 拖拽延时,效果更好看
group: {
// 是否开启跨表拖拽
pull: false,
put: false,
},
onChoose(e) {
if (_this.currentIndex == null) {
_this.currentIndex = e.oldIndex;
}
},
onMove: function (e, originalEven) {
/*
e.dragged; // 被拖拽的对象
e.related; // 被替换的对象
*/
//拖动之前记住原始的物理位置
if (_this.currentTop == null) {
_this.currentTop = e.draggedRect.top || null;
}
},
onEnd: (e) => {
// 这里主要进行数据的处理,拖拽实际并不会改变绑定数据的顺序,这里需要自己做数据的顺序更改
let arr = [];
let flag = false;
//sortablejs在对有children的列进行拖拽时,e.oldIndex会离谱地变成最低一级,而不是自身,所以放回自身位置的时候会触发其他操作
//可以用他的位置去判断有没有发生改变来决定要不要做其他操作
//物理位置没有发生改变
let top = e.item.toRect ? e.item.toRect.top : null;
if (top == _this.currentTop) {
flag = true;
}
//拖动时放回原位
if (e.oldIndex == e.newIndex || flag) return;
//扁平化数组
_this.tableData.map((item) => {
treeToArray(item, arr);
});
//同层级之间拖拽的处理:
// 1:向下拖-》把拖动前位置的accountid作为parentid,调接口getSortableData
// 2: 向上拖-》不调接口但更节点数据updateTableDate
// ->同层级单不是最外层跟节点之间的拖动:交换节点,如果有children也一并带过去
// ->最外层跟节点之间的拖动:交换最外层的2棵树
//不是同层级的时候
//1:向上拖-》parentid就是拖动前位置上一级的accountid
//2:像下拖-》把拖动前位置的accountid作为parentid,调接口getSortableData
if (arr[e.oldIndex].parentId == arr[e.newIndex].parentId) {
if (e.newIndex > e.oldIndex) {
//同层级往下拖
_this.$emit(
"getSortableData",
arr[e.oldIndex], //被托动的数据,
arr[e.newIndex][_this.rowKey]
);
} else {
//同层级往上拖
if (
arr[e.oldIndex].parentId == null &&
arr[e.newIndex].parentId == null
) {
//(同层级,最外层,上拖)最外层根节点互换
let oldDataIndex = null;
let newDataIndex = null;
_this.tableData.forEach((item, index) => {
if (item[_this.rowKey] == arr[e.oldIndex][_this.rowKey]) {
oldDataIndex = index;
}
if (item[_this.rowKey] == arr[e.newIndex][_this.rowKey]) {
newDataIndex = index;
}
});
if (newDataIndex > 0) {
//不是拖到最上面
_this.$emit(
"getSortableData",
arr[e.oldIndex], //被托动的数据,
arr[e.newIndex][_this.rowKey]
);
} else {
//最外成根节点互换
let currentIndex = _this.tableData.splice(oldDataIndex, 1)[0];
_this.tableData.unshift(currentIndex);
_this.$emit("updateTableDate", _this.tableData);
}
} else {
// 同层级但不是最外层级之间拖拽
_this.swapTreeNodes(
_this,
_this.tableData,
arr[e.oldIndex][_this.rowKey],
arr[e.newIndex][_this.rowKey]
);
_this.$emit("updateTableDate", _this.tableData);
}
}
} else {
// 不同层级往下拖
if (e.newIndex > e.oldIndex) {
_this.$emit(
"getSortableData",
arr[e.oldIndex], //被托动的数据,
arr[e.newIndex][_this.rowKey]
);
} else {
//不同层级向上拖
if (e.newIndex > 0) {
//不同层级向上拖,不是拖到最上面
_this.$emit(
"getSortableData",
arr[e.oldIndex], //被托动的数据,
arr[e.newIndex - 1][_this.rowKey]
);
} else {
//不同层级向上拖,拖到最上面
_this.$emit(
"getSortableData",
arr[e.oldIndex], //被托动的数据,
arr[e.newIndex].parentId
);
}
}
}
//拖动结束后重新置空
_this.currentTop = null;
},
});
},
swapTreeNodes(_this, tree, id1, id2) {
// 定义一个递归函数来查找节点
function findNode(node, id) {
if (node[_this.rowKey] === id) {
return node;
} else if (node.children) {
for (const child of node.children) {
const found = findNode(child, id);
if (found) {
return found;
}
}
}
return null;
}
// 查找要交换的两个节点和它们的父节点
let node1 = null; //原来节点
let node2 = null; //拖动位置原来的节点
let parent1 = null;
let parent2 = null;
for (const node of tree) {
const found1 = findNode(node, id1);
const found2 = findNode(node, id2);
if (found1) {
node1 = found1;
parent1 = findNode(node, found1.parentId);
}
if (found2) {
node2 = found2;
parent2 = findNode(node, found2.parentId);
}
}
// 如果找到了两个节点以及它们的父节点,进行交换
if (node1 && node2 && parent1 && parent2) {
const index1 = parent1.children.indexOf(node1);
const index2 = parent2.children.indexOf(node2);
//插入节点,先删除被拖动的节点
parent1.children.splice(index1, 1);
parent1.children.splice(index2, 0, node1);
}
// }
},
//调用按钮方法
handleButton(methods, row) {
this.$emit("handleButton", methods, row);
},
//当为树状复选框表格------------父组件调用获取勾选的数据
//通过row的isSelect来判断,如果页面是用toggleRowSelection去勾选数据的,需要手动row.isSelect = true
getSelection() {
const result = [];
const traverse = (node) => {
if (node.isSelect) {
const { children, ...res } = node;
result.push(res);
}
if (node.children) {
node.children.forEach(traverse);
}
};
this.tableData.forEach(traverse);
return result;
},
//复选框点击事件
selectFun(selection, row) {
//有父子集合的表格
if (this.complexities) {
this.setRowIsSelect(row);
this.$emit("selectChange");
} else {
//普通表格
this.$emit("selectChange", selection);
}
},
//复选框点击事件
setRowIsSelect(row) {
let _this = this;
//当点击父级点复选框时,当前的状态可能为未知状态,所以当前行状态设为false并选中,即可实现子级点全选效果
if (row.isSelect === "") {
row.isSelect = false;
_this.$refs.tableView.toggleRowSelection(row, true);
}
row.isSelect = !row.isSelect;
function selectAllChildrens(data) {
data.forEach((item) => {
item.isSelect = row.isSelect;
_this.$refs.tableView.toggleRowSelection(item, row.isSelect);
if (item.children && item.children.length) {
selectAllChildrens(item.children);
}
});
}
function getSelectStatus(selectStatuaArr, data) {
data.forEach((childrenItem) => {
selectStatuaArr.push(childrenItem.isSelect);
if (childrenItem.children && childrenItem.children.length) {
getSelectStatus(selectStatuaArr, childrenItem.children);
}
});
return selectStatuaArr;
}
function getLevelStatus(row) {
//如果当前节点的parantId =0 并且有子节点,则为1
//如果当前节点的parantId !=0 并且子节点没有子节点 则为3
if (row.parentId == null) {
if (row.children && row.children.length) {
return 1;
} else {
return 4;
}
} else {
if (!row.children || !row.children.length) {
return 3;
} else {
return 2;
}
}
}
let result = {};
//获取明确的节点,找到父节点
function getExplicitNode(data, parentId) {
data.forEach((item) => {
if (item[_this.rowKey] == parentId) {
result = item;
}
if (item.children && item.children.length > 0) {
getExplicitNode(item.children, parentId);
}
});
return result;
}
function operateLastLeve(row) {
//操作的是子节点 1、获取父节点 2、判断子节点选中个数,如果全部选中则父节点设为选中状态,如果都不选中,则为不选中状态,如果部分选择,则设为不明确状态
let selectStatuaArr = [];
let item = getExplicitNode(_this.tableData, row.parentId);
//item为row的父节点
selectStatuaArr = getSelectStatus(selectStatuaArr, item.children);
//判断父节点的所有孩子isSelect的状态
if (
selectStatuaArr.every((selectItem) => {
return true == selectItem;
})
) {
//全部子节点选中
item.isSelect = true;
_this.$nextTick(() => {
_this.$refs.tableView.toggleRowSelection(item, true);
});
} else if (
selectStatuaArr.some((selectItem) => {
return false == selectItem;
})
) {
//非全部子节点选中
item.isSelect = false;
_this.$refs.tableView.toggleRowSelection(item, false);
} else {
item.isSelect = "";
}
//则还有父级
if (item.parentId != null) {
operateLastLeve(item);
}
}
//判断操作的是子级点复选框还是父级点复选框,如果是父级点,则控制子级点的全选和不全选
//1、只是父级 2、既是子集,又是父级 3、只是子级
let levelSataus = getLevelStatus(row);
if (levelSataus == 1) {
selectAllChildrens(row.children);
} else if (levelSataus == 2) {
selectAllChildrens(row.children);
operateLastLeve(row);
} else if (levelSataus == 3) {
operateLastLeve(row);
}
},
//检测表格数据是否全选
checkIsAllSelect() {
let oneDataIsSelect = [];
this.tableData.forEach((item) => {
oneDataIsSelect.push(item.isSelect);
});
//判断一级产品是否是全选.如果一级产品全为true,则设置为取消全选,否则全选
let isAllSelect = oneDataIsSelect.every((selectStatusItem) => {
return true == selectStatusItem;
});
return isAllSelect;
},
//表格全选事件
selectAllFun(selection) {
if (this.complexities) {
let isAllSelect = this.checkIsAllSelect();
this.tableData.forEach((item) => {
item.isSelect = isAllSelect;
this.$refs.tableView.toggleRowSelection(item, !isAllSelect);
this.selectFun(selection, item);
});
this.$emit("selectChange");
} else {
this.$emit("selectChange", selection);
}
},
iconClick(row) {
this.$emit("iconClick", row);
},
//被选中的节点展开
setDefaultExpand() {
const selectedLeafNodes = [];
for (const node of this.tableData) {
this.findNodePath(node, [], selectedLeafNodes);
}
let data = Array.from(new Set(selectedLeafNodes.flat(Infinity)));
data.forEach((item) => {
this.$refs.tableView.toggleRowExpansion(item);
});
},
//查找isselect节点的路径
findNodePath(node, path = [], selectedLeaves = []) {
path.push(node);
if (node.isSelect && (!node.children || node.children.length === 0)) {
selectedLeaves.push([...path]);
}
if (node.children && node.children.length > 0) {
for (const child of node.children) {
this.findNodePath(child, path, selectedLeaves);
}
}
path.pop();
},
expandFun(row) {
row.isExpand = !row.isExpand;
this.$refs.tableView.toggleRowExpansion(row);
},
},
};
</script>
<style lang="scss" scoped>
a {
color: blue;
text-decoration: none; //取消下划线
}
a:hover {
//移动到超链接出现下划线
text-decoration: underline;
}
.baseTable ::v-deep .el-table__expand-icon {
display: none;
}
.baseTable ::v-deep .el-table__placeholder {
display: none;
}
</style>
js
//上面import进来的的treeToArray是这样的
export function treeToArray(obj, res = []) { // 默认初始结果数组为[]
res.push(obj); // 当前元素入栈
// 若元素包含children,则遍历children并递归调用使每一个子元素入栈
if (obj.children && obj.children.length) {
for (const item of obj.children) {
treeToArray(item, res);
}
}
return res;
}
组件的使用
js
<tableView
ref="tableView"
v-if="refresh"
:maxHeight="600"
:complexities="true"
:needSelection="true"
:columnData="columnData"
:tableData="tableData"
:tableOption="tableOption"
:maxLevel="maxLevel"
:rowKey="'id'"
:loading="loading"
:draggable="true"
@handleButton="handleButton"
@iconClick="shareholdingRatioUp"
@getSortableData="getSortableData"
@updateTableDate="updateTableDate"
>
</tableView>
getSortableData(oldRow, parentId) {
//oldrow为被拖动的一整条数据,parentid为被拖动后属于哪个父节点,拿到数据后给后台处理
console.log('oldRow, parentId',oldRow, parentId)
}
updateTableDate(tableData) {
this.$nextTick(() => {
this.tableData = tableData;
});
},
最后
如果有什么问题,可以下方留言评论,有空的话会尽快给各位解答!觉得有用可以点赞+收藏。