本组件实现了一个基于 Element Plus 的双树形结构穿梭框,支持以下核心功能:
采用经典的左右面板+操作按钮布局:
<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>
通过 Vue 的响应式系统实现数据同步:
const props = defineProps({
checkValue: { type: Array, default: () => [] },
titles: { type: Array, default: () => ['选择区域', '已选区域'] },
treeData: { type: Array, required: true }
})
const emit = defineEmits(['update:checkValue'])
实现节点过滤和状态同步:
// 左侧树禁用已选节点
const disableSelectedNodes = (treeData, selectedIds) => {
// 使用深拷贝处理树形数据
// 过滤已选中的叶子节点
}
// 右侧树数据过滤
const hadleRightTreeData = (treeData) => {
// 仅保留选中节点及其父节点
}
通过节点事件实现展开状态管理:
// 左侧树节点展开/折叠
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;
}
<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>