element-plus简易tree-transfer实现

发布于:2024-05-04 ⋅ 阅读:(33) ⋅ 点赞:(0)

我们最近的一个项目,需要在业务中实现一个树形穿梭框,穿梭框这个功能很常见,element-plus也有这个,但是问题来了,element-plus穿梭框只能支持一层数组,并不支持父子嵌套的树形结构。

我想这应该也不是什么大问题,我能碰到这个需求那就证明肯定有人也碰到并实现了这一需求,那我上网找找吧,但是这一找就发现要么不太符合我的需求,要么能找到但是是element-ui版本的,思来想去,看来只好自己造轮子了。这过程又是辗转坎坷就不多做介绍了,这里直接展示效果并写篇文章记录一下实现思路。

效果如下:

摸过的坑

其实我的业务需求很简单,就是需要一个穿梭框,让用户选中左边的移到右边,右边表示的就是最终选中的数据。那么问题就变得只需要解决一个问题:怎样实现将选中的树形数据移动到右边构建右边的树形结构展示,同时删除左边已选中的节点而不影响原有节点。

结果这个过程我绕了好多弯路,比如一开始我的想法是左右两边都加载一遍原始的全数据,选中左边的,利用v-if,将左边dom结构隐藏,然后反向找到右边的,将右边的非选中的节点用v-if隐藏。是不是听起来很绕,没错,我就是这么给绕晕了。并且发现这样的思路根本无法实现,在处理数据的时候容易造成死循环不说,直接在elemen-plus上写v-if也无法实现效果,element-plus根本不会隐藏,如果改用v-show则无法隐藏checkbox。而且还有一个比较致命的问题:如果我没有全选某一完整的父子结构,那么右边会因为选中的节点中没有父节点而无法渲染展示。这果然也是一个坑,难怪找了一圈发现没什么开发者去实现这个功能。

于是本着原汤化原食的想法,再去翻翻element-plus的文档吧,然后我发现或者说是我忽略了tree的半选这一概念:

image.png

这下思路打开了,这不正是我需要的东西吗,我逐渐理解一切了,所以最终实现的思路是这样的。

实现思路

当点击穿梭框向右按钮,代表需要将左边选中的数据移动到右边,此时

最终所选的数据 = 原先已选数据 + 左侧已选数据

左侧展示节点数据 = 左侧原有展示节点数据 去除 左侧已选节点数据

右侧展示节点数据 = 左侧半选节点数据 + 左侧已选节点数据 + 右侧原有节点数据

而当点击穿梭框向左按钮,则代表需要将右边选中的数据移动到左边,此时

左侧展示节点数据 = 右侧半选节点数据 + 右侧已选节点数据 + 左侧原有节点数据

右侧展示节点数据 = 右侧原先展示节点数据 去除 右侧已选节点数据

最终所选的数据 = 右侧节点数据

需要注意的是这样的顺序是不能变,不然你获取到的最终返回给父层的选中数据可能不全或有误。以下是实现代码。

tree-transfer

<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>

使用样例

<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已实现的功能实现数据的渲染就行了。希望这篇文章对你有帮助。