【Vue3 + Element-Plus】TreeTransfer树形穿梭框组件

基于 Element Plus 实现高效树形穿梭框组件

【Vue3 + Element-Plus】TreeTransfer树形穿梭框组件_第1张图片

组件概述

本组件实现了一个基于 Element Plus 的双树形结构穿梭框,支持以下核心功能:

  • 树形结构数据展示
  • 节点多选与批量转移
  • 展开状态记忆
  • 双向数据同步
  • 节点禁用与过滤
  • 全选/全不选功能(待完善)

核心实现思路

1. 组件结构设计

采用经典的左右面板+操作按钮布局:

<div class="transfer-container">
  
  <div class="tree-panel">...div>
  
  
  <div class="operation-buttons">
    <el-button @click="moveToRight">el-button>
    <el-button @click="moveToLeft">el-button>
  div>

  
  <div class="list-panel">...div>
div>

2. 数据双向绑定

通过 Vue 的响应式系统实现数据同步:

const props = defineProps({
  checkValue: { type: Array, default: () => [] },
  titles: { type: Array, default: () => ['选择区域', '已选区域'] },
  treeData: { type: Array, required: true }
})

const emit = defineEmits(['update:checkValue'])

3. 树形数据处理

实现节点过滤和状态同步:

// 左侧树禁用已选节点
const disableSelectedNodes = (treeData, selectedIds) => {
  // 使用深拷贝处理树形数据
  // 过滤已选中的叶子节点
}

// 右侧树数据过滤
const hadleRightTreeData = (treeData) => {
  // 仅保留选中节点及其父节点
}

4. 展开状态记忆

通过节点事件实现展开状态管理:

// 左侧树节点展开/折叠
const handleLeftNodeExpand = (node) => {
  if (!leftNodeExpand.value.includes(node.id)) {
    leftNodeExpand.value.push(node.id)
  }
}

const handleLeftNodeCollapse = (node) => {
  const index = leftNodeExpand.value.indexOf(node.id)
  if (index > -1) {
    leftNodeExpand.value.splice(index, 1)
  }
}

核心功能实现

节点转移逻辑

// 向右转移
const moveToRight = () => {
  const newValue = [...new Set([
    ...props.checkValue,
    ...leftCheckedKeys.value,
    ...leftHalfCheckedKeys.value
  ])]
  emit('update:checkValue', newValue)
}

// 向左转移
const moveToLeft = () => {
  const newValue = props.checkValue.filter(id => 
    !rightCheckedKeys.value.includes(id)
  )
  emit('update:checkValue', newValue)
}

状态同步机制

通过 watch 实现数据响应:

watch(() => props.checkValue, (newVal) => {
  leftTreeData.value = disableSelectedNodes(props.treeData, newVal)
  rightTreeData.value = hadleRightTreeData(props.treeData)
}, { immediate: true })

样式优化要点

.transfer-container {
  display: flex;
  justify-content: space-between;
  height: 400px;
}

.tree-panel, .list-panel {
  width: 15vw;
  border: 1px solid #ebeef5;
  border-radius: 4px;
}

.operation-buttons {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

使用示例


扩展建议

  1. 性能优化:对于大数据量使用虚拟滚动
  2. 搜索功能:增加节点搜索过滤
  3. 自定义节点:支持插槽化内容定制
  4. 拖拽支持:实现节点拖拽转移
  5. 异步加载:支持动态加载子树节点

完整代码

<template>
  <div class="transfer-container">
    <!-- 左侧树形面板 -->
    <div class="tree-panel">
      <div class="panel-header">
        <el-checkbox v-model="leftExpandAll" @change="handleLeftCheckedAll"><span style="font-weight: 600;">{{ titles[0]
            }}</span></el-checkbox>
      </div>
      <el-tree :data="leftTreeData" :props="defaultProps" show-checkbox node-key="id" @check="handleLeftCheck"
        :default-expanded-keys="defaultLeftNodeExpand" @node-expand="handleLeftNodeExpand"
        @node-collapse="handleLeftNodeCollapse" />
    </div>

    <!-- 中间操作按钮 -->
    <div class="operation-buttons">
      <el-button type="primary" :icon="ArrowRight" :disabled="showMoveToRight" @click="moveToRight">

      </el-button>
      <el-button style="margin-left: 0px;" type="primary" :icon="ArrowLeft" :disabled="showMoveToLeft"
        @click="moveToLeft">
      </el-button>
    </div>

    <!-- 右侧列表面板 -->
    <div class="list-panel">

      <div class="panel-header">
        <el-checkbox v-model="leftExpandAll" @change="handleRightCheckedAll"><span style="font-weight: 600;">{{
          titles[1] }}</span></el-checkbox>
      </div>
      <el-tree :data="rightTreeData" :props="defaultProps" show-checkbox node-key="id" @check="handleRightCheck"
        :default-expanded-keys="defaultRightNodeExpand" @node-expand="handleRightNodeExpand"
        @node-collapse="handleRightNodeCollapse" />
    </div>
  </div>
</template>

<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { ArrowRight, ArrowLeft } from '@element-plus/icons-vue'

const props = defineProps({
  checkValue: { // 已选择的值
    type: Array,
    default: () => []
  },
  titles: { // 
    type: Array,
    default: () => ['选择区域', '已选区域']
  },
  treeData: { // 原始树数据
    type: Array,
    default: () => [],
    required: true,
  }
})

const defaultProps = {
  children: 'children',
  label: 'label'
}
const emit = defineEmits(['update:checkValue'])
const defaultLeftNodeExpand = ref([])
const defaultRightNodeExpand = ref([])
const leftNodeExpand = ref([])
const rightNodeExpand = ref([])
const leftTreeData = ref([])
const rightTreeData = ref([])
const leftExpandAll = ref(false)
const showMoveToRight = ref(true) // 左侧选中数量
const showMoveToLeft = ref(true) // 左侧选中数量
const rightCheckedKeys = ref([])
const rightHalfCheckedKeys = ref([])
const leftCheckedKeys = ref([])
const leftHalfCheckedKeys = ref([])
const disableSelectedNodes = (treeData, selectedIds) => {
  console.log('disableSelectedNodes', selectedIds);

  // 使用深拷贝,确保不改变原来的 treeData
  const clonedTreeData = JSON.parse(JSON.stringify(treeData));
  const processNode = (nodes) => {
    return nodes.map(node => {
      // 如果当前节点的 id 在选中的 ID 列表中,且该节点是叶节点(没有子节点)
      if (selectedIds.includes(node.id) && ((node.type != 3 && node.children?.length == 0) || (!node.children || node.children.length == 0))) {
        // 不返回该节点,表示删除
        return null;
      }
      // 如果当前节点有子节点,递归处理子节点
      if (node.children && node.children.length > 0) {
        node.children = processNode(node.children).filter(child => child !== null); // 过滤掉已删除的节点
      }
      if ((selectedIds.includes(node.id) && ((node.type != 3 && node.children?.length == 0) || (!node.children || node.children.length == 0)))) {
        return null
      }
      return node;
    }).filter(node => node !== null); // 过滤掉已删除的节点
  };

  // 处理并返回新的树形数据
  return processNode(clonedTreeData);
}
const hadleRightTreeData = (treeData) => {

  const clonedTreeData = JSON.parse(JSON.stringify(treeData));
  const filterRightTreeData = (data) => {
    return data.filter(node => {
      if (!props.checkValue.includes(node.id)) return false
      if (node.children) {
        node.children = filterRightTreeData(node.children)
      }
      return true
    })
  }
  return filterRightTreeData(clonedTreeData)
}
watch(
  () => props.checkValue,
  (newVal) => {
    leftTreeData.value = disableSelectedNodes(props.treeData, newVal)
    rightTreeData.value = hadleRightTreeData(props.treeData)
    console.log('defaultLeftNodeExpand.value ',defaultLeftNodeExpand.value );
  },
  { immediate: true }
)

// 左侧复选框变化
const handleLeftCheck = (node, { checkedKeys, checkedNodes, halfCheckedNodes, halfCheckedKeys }) => {
  leftCheckedKeys.value = checkedKeys
  leftHalfCheckedKeys.value = halfCheckedKeys
  const selectedCount = checkedKeys.length
  showMoveToRight.value = selectedCount == 0;
}
// 右侧复选框变化
const handleRightCheck = (node, { checkedKeys, checkedNodes, halfCheckedNodes, halfCheckedKeys }) => {
  rightCheckedKeys.value = checkedKeys
  rightHalfCheckedKeys.value = halfCheckedKeys
  const selectedCount = checkedKeys.length
  showMoveToLeft.value = selectedCount == 0;
}
// 移动到右侧
const moveToRight = () => {
  const newValue = [...new Set([...props.checkValue, ...leftCheckedKeys.value, ...leftHalfCheckedKeys.value])]
  emit('update:checkValue', newValue)   
  defaultRightNodeExpand.value = [...leftNodeExpand.value, ...rightNodeExpand.value]
  defaultLeftNodeExpand.value = [...leftNodeExpand.value, ...rightNodeExpand.value]
  console.log('defaultRightNodeExpand.value', defaultRightNodeExpand.value);
  console.log('defaultLeftNodeExpand.value', defaultLeftNodeExpand.value);
  
  leftCheckedKeys.value = []
  showMoveToRight.value = true
}
// 移动到左侧
const moveToLeft = () => {
  const newValue = []
  props.checkValue.forEach(id => {
    if (!rightCheckedKeys.value.includes(id)) {
      newValue.push(id)
    }
  })
  rightHalfCheckedKeys.value.forEach(id => {
    if (!newValue.includes(id)) {
      newValue.push(id)
    }
  })
  emit('update:checkValue', newValue)
  defaultRightNodeExpand.value = [...leftNodeExpand.value, ...rightNodeExpand.value]
  defaultLeftNodeExpand.value = [...leftNodeExpand.value, ...rightNodeExpand.value]
  console.log('defaultRightNodeExpand.value', defaultRightNodeExpand.value);
  console.log('defaultLeftNodeExpand.value', defaultLeftNodeExpand.value);
  showMoveToLeft.value = true
}
// 左侧全选/全不选
const handleLeftCheckedAll = () => {
}
// 右侧全选/全不选
const handleRightCheckedAll = () => {
}

const handleLeftNodeExpand = (va1) => {
  const leftNodeExpandId = leftNodeExpand.value
  // 保存展开节点
  if (!leftNodeExpandId.includes(va1.id)) {
    leftNodeExpandId.push(va1.id)
  }
  
  console.log('leftNodeExpandId', leftNodeExpand.value);
}
const handleLeftNodeCollapse = (va1) => {
  const leftNodeExpandId = leftNodeExpand.value
  // 去除展开节点
  if (leftNodeExpandId.includes(va1.id)) {
    leftNodeExpandId.splice(leftNodeExpandId.indexOf(va1.id), 1)
  }
  console.log('leftNodeExpandId', leftNodeExpand.value);
  
}

const handleRightNodeExpand = (va1) => {
  const rightNodeExpandId = rightNodeExpand.value
  // 保存展开节点
  if (!rightNodeExpandId.includes(va1.id)) {
    rightNodeExpandId.push(va1.id)
  }
  
  console.log('rightNodeExpandId', rightNodeExpand.value);
}
const handleRightNodeCollapse = (va1) => {
  const rightNodeExpandId = rightNodeExpand.value
  // 去除展开节点
  if (rightNodeExpandId.includes(va1.id)) {
    rightNodeExpandId.splice(rightNodeExpandId.indexOf(va1.id), 1)
  }
  console.log('rightNodeExpandId', rightNodeExpand.value);
  
}
</script>
<style scoped>
.transfer-container {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1px;
  background: #fff;
}

.tree-panel,
.list-panel {
  width: 15vw;
  height: 400px;
  border: 1px solid #ebeef5;
  border-radius: 4px;
}

.panel-header {
  background-color: #f5f7fa;
  padding: 4px 10px;
  border-bottom: 1px solid #ebeef5;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.operation-buttons {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 7px;
}

.el-tree {
  height: calc(400px - 42px);
  overflow: auto;
}

.el-scrollbar {
  height: calc(400px - 42px);
}

.list-item {
  padding: 8px 15px;
  transition: background-color 0.3s;
}

.list-item:hover {
  background-color: #f5f7fa;
}
</style>

你可能感兴趣的:(vue.js,elementui)