element-plus简易tree-transfer实现

我们最近的一个项目,需要在业务中实现一个树形穿梭框,穿梭框这个功能很常见,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已实现的功能实现数据的渲染就行了。希望这篇文章对你有帮助。

相关推荐
百万蹄蹄向前冲1 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5812 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路2 小时前
GeoTools 读取影像元数据
前端
ssshooter3 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry3 小时前
Jetpack Compose 中的状态
前端
dae bal4 小时前
关于RSA和AES加密
前端·vue.js
柳杉4 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog4 小时前
低端设备加载webp ANR
前端·算法
LKAI.5 小时前
传统方式部署(RuoYi-Cloud)微服务
java·linux·前端·后端·微服务·node.js·ruoyi
刺客-Andy5 小时前
React 第七十节 Router中matchRoutes的使用详解及注意事项
前端·javascript·react.js