我们最近的一个项目,需要在业务中实现一个树形穿梭框,穿梭框这个功能很常见,element-plus也有这个组件,但是问题来了,element-plus穿梭框只能支持一层数组,并不支持父子嵌套的树形结构。
我想这应该也不是什么大问题,我能碰到这个需求那就证明肯定有人也碰到并实现了这一需求,那我上网找找吧,但是这一找就发现要么不太符合我的需求,要么能找到但是是element-ui版本的,思来想去,看来只好自己造轮子了。这过程又是辗转坎坷就不多做介绍了,这里直接展示效果并写篇文章记录一下实现思路。
效果如下:
摸过的坑
其实我的业务需求很简单,就是需要一个穿梭框,让用户选中左边的移到右边,右边表示的就是最终选中的数据。那么问题就变得只需要解决一个问题:怎样实现将选中的树形数据移动到右边构建右边的树形结构展示,同时删除左边已选中的节点而不影响原有节点。
结果这个过程我绕了好多弯路,比如一开始我的想法是左右两边都加载一遍原始的全数据,选中左边的,利用v-if,将左边dom结构隐藏,然后反向找到右边的,将右边的非选中的节点用v-if隐藏。是不是听起来很绕,没错,我就是这么给绕晕了。并且发现这样的思路根本无法实现,在处理数据的时候容易造成死循环不说,直接在elemen-plus上写v-if也无法实现效果,element-plus根本不会隐藏,如果改用v-show则无法隐藏checkbox。而且还有一个比较致命的问题:如果我没有全选某一完整的父子结构,那么右边会因为选中的节点中没有父节点而无法渲染展示。这果然也是一个坑,难怪找了一圈发现没什么开发者去实现这个功能。
于是本着原汤化原食的想法,再去翻翻element-plus的文档吧,然后我发现或者说是我忽略了tree的半选这一概念:
这下思路打开了,这不正是我需要的东西吗,我逐渐理解一切了,所以最终实现的思路是这样的。
实现思路
当点击穿梭框向右按钮,代表需要将左边选中的数据移动到右边,此时
最终所选的数据 = 原先已选数据 + 左侧已选数据
左侧展示节点数据 = 左侧原有展示节点数据 去除 左侧已选节点数据
右侧展示节点数据 = 左侧半选节点数据 + 左侧已选节点数据 + 右侧原有节点数据
而当点击穿梭框向左按钮,则代表需要将右边选中的数据移动到左边,此时
左侧展示节点数据 = 右侧半选节点数据 + 右侧已选节点数据 + 左侧原有节点数据
右侧展示节点数据 = 右侧原先展示节点数据 去除 右侧已选节点数据
最终所选的数据 = 右侧节点数据
需要注意的是这样的顺序是不能变,不然你获取到的最终返回给父层的选中数据可能不全或有误。以下是实现代码。
tree-transfer
javascript
<template>
<div class="tree-transfer">
<div class="left-transfer">
<el-input class="search-input" v-model="filterLeftText" placeholder="Filter keyword"/>
<el-scrollbar :height="height">
<el-tree
class="left-tree"
ref="treeLeftRef"
:data="fromDataLeft"
:props="props.defaultProps"
show-checkbox
default-expand-all
:node-key="props.defaultProps.value"
highlight-current
@check-change="leftCheckChange"
:filter-node-method="filterLeftNode"
style="max-width: 600px"
/>
</el-scrollbar>
</div>
<div class="middle-btns">
<span><el-button type="primary" plain icon="Back" @click="toLeft"></el-button></span>
<span><el-button type="primary" plain icon="Right" @click="toRight"></el-button></span>
</div>
<div class="right-transfer">
<el-input class="search-input" v-model="filterRightText" placeholder="Filter keyword"/>
<el-scrollbar :height="height">
<el-tree
class="right-tree"
ref="treeRightRef"
:data="fromDataRight"
:props="props.defaultProps"
show-checkbox
default-expand-all
:node-key="props.defaultProps.value"
highlight-current
@check-change="rightCheckChange"
:filter-node-method="filterRightNode"
style="max-width: 600px"
/>
</el-scrollbar>
</div>
</div>
</template>
<script setup name="TreeTransfer">
const { proxy } = getCurrentInstance();
const props = defineProps({
data: {
type: Array,
default: () => []
},
defaultProps: {
type: Object,
default: () => {
return {
children: 'children',
label: 'label',
value: 'value',
}
}
},
defaultCheckedKeys: {
type: Array,
default: () => []
},
height: {
type: String,
default: '400px'
}
});
const emits = defineEmits(['update:toData']);
// 左侧树展示数据
const fromDataLeft = props.defaultCheckedKeys.length > 0 ? ref(findNonMatches(props.data, props.defaultCheckedKeys)) : ref(JSON.parse(JSON.stringify(props.data)));
// 左侧半选择数据列表
let leftHalfChecked = [];
// 右侧树展示数据
const fromDataRight = props.defaultCheckedKeys.length > 0 ? ref(findMatches(props.data, props.defaultCheckedKeys)) : ref([]);
// 右侧半选择数据列表
let rightHalfChecked = [];
// 最终显示的选择数据
const toData = props.defaultCheckedKeys && props.defaultCheckedKeys.length > 0 ? ref(props.defaultCheckedKeys) : ref([]);
// 初始化默认传给到父层获取数据的数组
emits("update:toData", [...toData.value]);
/** 递归获取所有id */
function extractIds(arr) {
const ids = [];
for (const item of arr) {
ids.push(item[props.defaultProps.value]);
if (item.children) {
const childIds = extractIds(item.children);
ids.push(...childIds);
}
}
return ids;
}
/** 递归筛选匹配的id */
function findMatches(arr1, arr2, matches = true) {
const result = [];
arr1.forEach(item => {
const match = arr2.includes(item[props.defaultProps.value]);
if ((matches && match) || (!matches && !match)) {
const newItem = { ...item };
if (newItem.children) {
newItem.children = findMatches(newItem.children, arr2, matches);
}
result.push(newItem);
}
});
return result;
}
/** 递归筛选非匹配的id */
function findNonMatches(arr1, arr2) {
const result = [];
arr1.forEach(item => {
const match = arr2.includes(item[props.defaultProps.value]);
if (!match) {
const newItem = { ...item };
if (newItem.children) {
newItem.children = findNonMatches(newItem.children, arr2);
}
result.push(newItem);
}
});
return result;
}
/** 将两个数组合并为新数组*/
function concatData(arr1, arr2) {
const uniqueValues = new Set([...arr1, ...arr2]);
const newData = Array.from(uniqueValues);
return newData;
}
// 左侧相关
const treeLeftRef = ref(null);
const filterLeftText = ref("");
const toLeftData = ref([]);
/** 左侧数选择 */
const getLeftCheckedNodes = () => {
return treeLeftRef.value.getCheckedNodes(false, false)
}
const getLeftCheckedKeys = () => {
return treeLeftRef.value.getCheckedKeys(false)
}
function leftCheckChange(data, checked, indeterminate) {
leftHalfChecked = treeLeftRef.value.getHalfCheckedKeys();
toLeftData.value = getLeftCheckedKeys();
}
const filterLeftNode = (value, data) => {
if (!value) return true
return data.label.includes(value)
}
watch(filterLeftText, (val) => {
treeLeftRef.value.filter(val)
});
// 右侧相关
const treeRightRef = ref(null);
const filterRightText = ref("");
const toRightData = ref([]);
/** 左侧数选择 */
const getRightCheckedNodes = () => {
return treeRightRef.value.getCheckedNodes(false, false);
}
const getRightCheckedKeys = () => {
return treeRightRef.value.getCheckedKeys(false);
}
function rightCheckChange(data, checked, indeterminate) {
rightHalfChecked = treeRightRef.value.getHalfCheckedKeys();
toRightData.value = getRightCheckedKeys();
}
const filterRightNode = (value, data) => {
if (!value) return true
return data.label.includes(value)
}
watch(filterRightText, (val) => {
treeRightRef.value.filter(val)
});
// 点击想着按钮函数
function toLeft() {
let leftData = [...rightHalfChecked, ...toRightData.value, ...extractIds(fromDataLeft.value)];
fromDataLeft.value = findMatches(props.data, leftData);
fromDataRight.value = findNonMatches(fromDataRight.value, [...toRightData.value]);
toData.value = extractIds(fromDataRight.value);
emits("update:toData", [...toData.value]);
toLeftData.value = [];
toRightData.value = [];
leftHalfChecked = [];
rightHalfChecked = [];
}
// 点击向右按钮函数
function toRight() {
toData.value = concatData(toData.value, [...toLeftData.value]);
fromDataLeft.value = findNonMatches(fromDataLeft.value, [...toLeftData.value]);
let rightData = [...leftHalfChecked, ...extractIds(fromDataRight.value), ...toLeftData.value];
fromDataRight.value = findMatches(props.data, rightData);
emits("update:toData", [...toData.value]);
toLeftData.value = [];
toRightData.value = [];
leftHalfChecked = [];
rightHalfChecked = [];
}
</script>
<style lang="scss" scoped>
.tree-transfer {
display: flex;
justify-content: space-between;
flex-wrap: nowrap;
gap: 5px;
}
.search-input {
min-width: 240px;
margin-bottom: 10px;
}
.left-transfer, .right-transfer {
flex: 1 1 auto;
border: 1px solid var(--el-border-color);
}
.middle-btns {
align-content: center;
& > span {
display: block;
margin-bottom: 5px;
}
}
</style>
使用样例
javascript
<template>
<div>
<tree-transfer
:data="testData"
:defaultProps="defaultProps"
:defaultCheckedKeys="defaultCheckedKeys"
v-model:toData="results">
</tree-transfer>
results: {{ results }}
</div>
</template>
<script setup name="MyTask">
import TreeTransfer from '@/components/TreeTransfer';
const testData = ref([
{
value: '1',
id: '1',
label: 'Level one 1',
children: [
{
value: '1-1',
id: '1-1',
label: 'Level two 1-1',
children: [
{
value: '1-1-1',
id: '1-1-1',
label: 'Level three 1-1-1',
},
],
},
],
},
{
value: '2',
id: '2',
label: 'Level one 2',
children: [
{
value: '2-1',
id: '2-1',
label: 'Level two 2-1',
children: [
{
value: '2-1-1',
id: '2-1-1',
label: 'Level three 2-1-1',
},
],
},
{
value: '2-2',
id: '2-2',
label: 'Level two 2-2',
children: [
{
value: '2-2-1',
id: '2-2-1',
label: 'Level three 2-2-1',
},
],
},
],
},
{
value: '3',
id: '3',
label: 'Level one 3',
children: [
{
value: '3-1',
id: '3-1',
label: 'Level two 3-1',
children: [
{
value: '3-1-1',
id: '3-1-1',
label: 'Level three 3-1-1',
},
],
},
{
value: '3-2',
id: '3-2',
label: 'Level two 3-2',
children: [
{
value: '3-2-1',
id: '3-2-1',
label: 'Level three 3-2-1',
},
],
},
],
},
]);
const defaultProps = {
children: 'children',
label: 'label',
value: 'id',
}
const defaultCheckedKeys = ["2", "2-1", "2-1-1", "2-2", "2-2-1"];
const results = ref([]);
</script>
总结
现在想来,其实实现思路并不复杂,我想做的也只是在elemen-plus原有的基础上将穿梭框和树形结构结合起来就行,实现的关键就在于利用el-tree的半选结构合理保留非全选整个树形数据的情况再利用element-plus已实现的功能实现数据的渲染就行了。希望这篇文章对你有帮助。