基于 Element Plus 实现高效树形穿梭框组件
组件概述
本组件实现了一个基于 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;
}
使用示例
<TreeTransfer :treeData="treeData"v-model:checkValue="selectedValues":titles="['可选部门', '已选部门']"
/>
扩展建议
- 性能优化:对于大数据量使用虚拟滚动
- 搜索功能:增加节点搜索过滤
- 自定义节点:支持插槽化内容定制
- 拖拽支持:实现节点拖拽转移
- 异步加载:支持动态加载子树节点
完整代码
<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);// 使用深拷贝,确保不改变原来的 treeDataconst 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 falseif (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 = checkedKeysleftHalfCheckedKeys.value = halfCheckedKeysconst selectedCount = checkedKeys.lengthshowMoveToRight.value = selectedCount == 0;
}
// 右侧复选框变化
const handleRightCheck = (node, { checkedKeys, checkedNodes, halfCheckedNodes, halfCheckedKeys }) => {rightCheckedKeys.value = checkedKeysrightHalfCheckedKeys.value = halfCheckedKeysconst selectedCount = checkedKeys.lengthshowMoveToLeft.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>