基于vue3和elementPlus的el-tree组件,实现树结构穿梭框,支持数据回显和懒加载

一、功能

功能描述

  • 数据双向穿梭:支持从左侧向右侧转移数据,以及从右侧向左侧转移数据。
  • 懒加载支持:支持懒加载数据,适用于大数据量的情况。
  • 多种展示形式:右侧列表支持以树形结构或列表形式展示。
  • 全选与反选:支持全选和全不选操作,以及保持树形结构的层级关系。
  • 搜索过滤:支持对左侧和右侧数据进行搜索过滤。
  • 自定义节点内容:支持自定义右侧列表中每个节点的内容

配置选项:
nodeKey:节点的主键,用于唯一标识每个节点。
leftTitlerightTitle:左侧和右侧树形列表的标题。
lazy:是否开启懒加载,当设置为 true 时,需要通过 loadMethod 方法加载数据。
loadMethod:懒加载时,用于加载数据的方法。
defaultProps:树节点的默认属性,包括标签、子节点和禁用状态。
leftDatarightData:左侧和右侧树形列表的数据。
defaultSelectionKeys:默认选中的数据的 ID 列表。
isSort:是否对右侧数据进行排序。
defaultExpandAll:是否默认展开所有节点。
checkOnClickNode:是否在点击节点的时候选中节点,默认值为 false,即只有在点击复选框时才会选中节点。
expandOnClickNode:是否在点击节点时展开或收缩节点。
isToList:是否将右侧数据展示为列表形式。

事件:
checkVal:当选中数据发生变化时触发的事件,返回当前选中的数据。

二、使用

1、 tree to list

使用 :

<script lang="ts" setup>
import { ref } from 'vue'
const transferProps = ref({
	label: 'name',
	children: 'children',
	disabled: 'disabled',
	isLeaf: 'leaf',
})

const checkVal = (val: any) => {
	 console.log('checkVal  ; ', val)
}

const loadNode = async (pid = 0) => {
	return new Promise((resolve) => {
		// 模拟网络请求延迟
		setTimeout(() => {
			// 假数据,树结构
			let data

			if (pid === 0) {
				data = [{ pid: 0, id: 1, name: 'region' }]
			} else if (pid === 1) {
				data = [{ pid: 1, id: 2, name: 'region1-1' }, { pid: 1, id: 3, name: 'region1-2', leaf: true }]
			} else if (pid === 2) {
				data = [{ pid: 2, id: 4, name: 'region2-1' }, { pid: 2, id: 5, name: 'region2-2', leaf: true }, { pid: 2, id: 6, name: 'region2-3', leaf: true }]
			} else {
				data = []
			}
			// 返回对应父节点的子节点
			resolve(data || [])
		}, 300) // 模拟延迟
	})
}
</script>

<template>
	<div>
		<ZtTreeTransfer
			:default-props="transferProps"
			:load-method="loadNode"
			node-key="id"
			is-select-all-nodes
			is-sort
			is-to-list
			lazy
			@check-val="checkVal"
		/>
	</div>
</template>

2、 tree to tree

可以配置默认选中的数据的ids,显示在右侧列表,以实现数据回显

使用 :

<script lang="ts" setup>
import { ZtTreeTransfer } from '@zt-components/components'

import { ref } from 'vue'

const fromData = ref([
	{
		id: 1,
		label: '1Level one 1',
		children: [
			{
				id: 4,
				label: '1-1',
				children: [
					{
						id: 9,
						label: '1-1-1',
					},
					{
						id: 10,
						label: '1-1-2',
					},
				],

			},
		],
	},
	{
		id: 2,
		label: '2Level one 2',
		children: [
			{
				id: 5,
				label: '2-1',
			},
			{
				id: 6,
				label: '2-2',
			},
		],
	},
	{
		id: 3,
		label: '3Level one 31111111',
		children: [
			{
				id: 7,
				label: '3-111111111111111111',
				disabled: true,
			},
			{
				id: 8,
				label: 'Level two 3-21111111',
				disabled: true,
				children: [
					{
						id: 11,
						label: '4-111111111111111111111',
					},
					{
						id: 12,
						label: '4-211111111111111111111',
					},
				],
			},
		],
	},
]) // 树形数据
const toData = ref([9, 10]) // 选中的ids数据
const transferProps = ref({
	label: 'label',
	children: 'children',
	disabled: 'disabled',
})

const checkVal = (val: any) => {
	console.log(val)
}
</script>

<template>
	<div>
		<ZtTreeTransfer
			:default-props="transferProps"
			:default-selection-keys="toData"
			:left-data="fromData"
			node-key="id"
			default-expand-all
			is-select-all-nodes
			is-sort
			@check-val="checkVal"
		/>
	</div>
</template>

三、代码实现

<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'
import { ArrowLeft, ArrowRight, Search } from '@element-plus/icons-vue'

/* 定义props */
const props: TreeTransferProps = defineProps({
	// 主键
	nodeKey: {
		type: String,
		default: 'id',
	},
	// 左侧标题
	leftTitle: {
		type: String,
		default: () => {
			return '全部列表'
		},
	},
	// 右侧标题
	rightTitle: {
		type: String,
		default: () => {
			return '已选列表'
		},
	},
	// 是否开启懒加载
	lazy: { type: Boolean, default: false },
	// 懒加载时,加载数据的方法
	loadMethod: { type: Function, required: false },
	// tree绑定的props
	defaultProps: {
		type: Object,
		default: () => ({
			label: 'label',
			children: 'children',
			disabled: 'disabled',
		}),
	},
	// 左侧树结构数据
	leftData: {
		type: Array,
		default: () => {
			return []
		},
	},
	// 默认选中的数据的ids,显示在右侧列表
	defaultSelectionKeys: {
		type: Array,
		default: () => {
			return []
		},
	},
	// 右侧数据是否按顺序排序 仅在平铺展开是有效  只支持按住键正序排序
	isSort: {
		type: Boolean,
	},
	defaultExpandAll: {
		type: Boolean,
		default: false,
	},
	// 是否在点击节点的时候选中节点,默认值为 false,即只有在点击复选框时才会选中节点。
	checkOnClickNode: {
		type: Boolean,
		default: false,
	},
	// 是否在点击节点的时候展开或者收缩节点, 默认值为 true,如果为 false,则只有点箭头图标的时候才会展开或者收缩节点。
	expandOnClickNode: {
		type: Boolean,
		default: true,
	},
	// 选择右侧所选数据的展示类型,默认是tree,true时为list
	isToList: {
		type: Boolean,
		default: false,
	},
}) // 又侧筛选条件

/* 定义emit */
const emit = defineEmits(['checkVal'])

/**
 * 定义props类型
 */
export interface TreeTransferProps {
	nodeKey: any
	leftTitle: any
	rightTitle: any
	defaultProps: any
	leftData: any
	defaultSelectionKeys: any
	isSort: boolean
	defaultExpandAll: Array<any>
	checkOnClickNode: boolean
	expandOnClickNode: boolean
	isToList: any
	loadMethod: Function
	lazy: boolean
}

const isCheckedAllLeft = ref(false) // 左侧全选框是否选中
const isCheckedAllRight = ref(false) // 右侧全选框是否选中

const isLeftCheckAllBoxDisabled = ref(false) // 左侧全选框是否禁用
const isRightCheckAllBoxDisabled = ref(false) // 右侧全选框是否禁用

const leftTreeRef = ref() // 左侧树ref
const rightTreeRef = ref() // 右侧树ref

const leftFilterText = ref('') // 左侧筛选条件
const rightFilterText = ref('')
const leftTreeData = ref([]) // 左侧tree数据
// 用于在右侧显示的数据列表
const rightData = ref([]) // 右侧列表结构数据
const rightTreeData = ref([]) // 右侧树结构数据

// 数组打平
const flattenTree = (treeData: any[], defaultProps: any): any[] => {
	let flatData: any[] = []
	treeData.forEach((node) => {
		flatData.push(node)
		if (node[defaultProps.children] && node[defaultProps.children].length) {
			flatData = flatData.concat(flattenTree(node[defaultProps.children], defaultProps))
		}
	})
	return flatData
}

// 校验树是否全选
const checkedAllTrue = (treeRef: any, treeData: any[], nodeKey: any, defaultProps: any): boolean => {
	// 校验是否全选
	const allKeys: string[] = treeRef.getCheckedKeys()
	const allNodes: any[] = flattenTree(treeData, defaultProps)
	const allKeysSet: Set<string> = new Set(allKeys)
	const allNodesSet: Set<string> = new Set(allNodes.map(node => node[nodeKey]))

	return allKeysSet.size === allNodesSet.size && [...allKeysSet].every(key => allNodesSet.has(key))
}

// 深拷贝
const deepClone = (obj: any): any => {
	// 判断拷贝的obj是对象还是数组
	const objClone: any = Array.isArray(obj) ? [] : {}
	if (obj && typeof obj === 'object') {
		// obj不能为空,并且是对象或者是数组 因为null也是object
		for (const key in obj) {
			if (obj.hasOwnProperty(key)) {
				if (obj[key] && typeof obj[key] === 'object') {
					// obj里面属性值不为空并且还是对象,进行深度拷贝
					objClone[key] = deepClone(obj[key]) // 递归进行深度的拷贝
				} else {
					objClone[key] = obj[key] // 直接拷贝
				}
			}
		}
	}
	return objClone
}

// 校验是否树节点是否全部禁用 nodes: []
const checkAllDisabled = (nodes: any[], defaultProps: any): boolean => {
	if (!(nodes && Array.isArray(nodes))) return false

	for (const node of nodes) {
		// 如果当前节点的disabled不是true,则直接返回false
		if (!node[defaultProps.disabled]) {
			return false
		}
		// 如果当前节点有子节点,则递归检查子节点
		if (node[defaultProps.children]?.length) {
			const childrenAreDisabled = checkAllDisabled(node[defaultProps.children], defaultProps)
			// 如果子节点中有任何disabled不是true,则返回false
			if (!childrenAreDisabled) {
				return false
			}
		}
	}
	// 如果所有节点的disabled都是true,则返回true
	return true
}

// 设置数组的某个字段值为某个参数
const setFieldValue = (array: any[], field: string, value: any, defaultProps: any) => {
	// 遍历数组中的每个元素
	array.forEach((item) => {
		// 如果元素是对象且有属性,则设置字段值
		if (typeof item === 'object' && item !== null) {
			item[field] = value
			// 如果元素有子数组,递归调用函数
			if (Array.isArray(item[defaultProps.children])) {
				setFieldValue(item[defaultProps.children], field, value, defaultProps)
			}
		}
	})
}

// 设置禁用
const setTreeIsDisabled = (data: any[], nodeKeysToDisable: string[], nodeKey: string, defaultProps: any, flag = true) => {
	if (!data || !data.length) return
	data.forEach((item) => {
		if (nodeKeysToDisable && nodeKeysToDisable.length && nodeKeysToDisable.includes(item[nodeKey])) {
			// 如果当前节点的id主键在要禁用的id主键列表中,设置disabled为true
			item[defaultProps.disabled] = flag
		}
		// 如果当前节点有children,递归调用函数
		const itemChildren = item[defaultProps.children]
		if (itemChildren && Array.isArray(itemChildren)) {
			setTreeIsDisabled(itemChildren, nodeKeysToDisable, nodeKey, defaultProps, flag)
		}
	})
}

// 获取数组中disabled的节点的Ids
const getDisabledNodeIds = (nodes: any[], nodeKey: string, defaultProps: any): string[] => {
	const disabledIds: string[] = []

	function traverse(node: any) {
		if (node.disabled) {
			disabledIds.push(node[nodeKey])
		}
		if (node[defaultProps.children]?.length) {
			node[defaultProps.children].forEach((child: any) => traverse(child))
		}
	}

	nodes.forEach(node => traverse(node))
	return disabledIds
}

// 递归校验 当子节点全部被禁用时 ,则设置其父节点也禁用
const updateDisabledStatus = (nodes: any[], defaultProps: any) => {
	nodes.forEach((node) => {
		// 首先检查当前节点是否有子节点
		if (node[defaultProps.children]?.length) {
			// 假设当前节点的所有子节点都是禁用的
			let allChildrenDisabled = true

			// 递归检查所有子节点的disabled状态
			node[defaultProps.children].forEach((child: any) => {
				// 如果子节点有子节点,递归调用
				if (child[defaultProps.children]?.length) {
					updateDisabledStatus([child], defaultProps) // 递归更新子节点状态
				}
				// 更新子节点的disabled状态
				child[defaultProps.disabled] = child[defaultProps.children].length > 0
					? child[defaultProps.children].every((c: any) => c[defaultProps.disabled])
					: child[defaultProps.disabled]

				// 如果发现任何一个子节点没有被禁用,更新假设
				if (!child[defaultProps.disabled]) {
					allChildrenDisabled = false
				}
			})

			// 更新当前节点的disabled状态
			node[defaultProps.disabled] = allChildrenDisabled
		}
	})
}

// 左侧输入框过滤事件
const filterLeftNode = (value, data) => {
	if (!value) return true
	return data[props.defaultProps.label].includes(value)
}

// 右侧输入框过滤事件
const filterRightNode = (value, data) => {
	if (!value) return true
	return data[props.defaultProps.label].includes(value)
}

// 右侧数据按顺序排序
const sortRightListByKey = () => {
	if (!props.isSort) return rightData.value
	return rightData.value.sort((a, b) => a[props.nodeKey] - b[props.nodeKey])
}

// 递归函数,用于构建只包含 ids 数组中 id 的树结构
const filterTreeByIds = (treeData, ids) => {
	return treeData
		.map((node) => {
			// 创建一个新节点对象,避免直接修改原始数据
			const newNode = { ...node }
			newNode[props.defaultProps.disabled] = false

			// 如果当前节点的 id 在 ids 中,保留这个节点及其子节点
			if (ids.includes(node[props.nodeKey])) {
				// 递归地过滤子节点
				newNode[props.defaultProps.children] = filterTreeByIds(node[props.defaultProps.children] || [], ids)
			} else {
				// 如果当前节点的 id 不在 ids 中,但有子节点,递归地过滤子节点
				// 同时,如果子节点中有至少一个节点的 id 在 ids 中,保留当前节点
				newNode[props.defaultProps.children] = filterTreeByIds(node[props.defaultProps.children] || [], ids).filter(child => child !== null)
			}

			// 如果当前节点的 id 不在 ids 中,且没有子节点或子节点都不在 ids 中,则不保留这个节点
			if (!ids.includes(node[props.nodeKey]) && (!newNode[props.defaultProps.children] || newNode[props.defaultProps.children].length === 0)) {
				return null
			}

			// 返回新的节点对象
			return newNode
		})
		.filter(node => node !== null) // 过滤掉 null 节点
}

// 去右边
const toRight = () => {
	/*  右侧显示的数据获取 */
	rightTreeData.value = getRightTreeData()
	rightData.value = getRightListData()

	// 给父组件抛出已选择的数据
	checkVal()

	/*
	 *  更新移动后的左侧树的节点状态 和全选按钮状态
	 *    先给所有已右移的节点设置禁用
	 *    再通过递归计算是否将子节点的父节点也设置禁用(子节点全部禁用时,将其父节点也禁用)
	 *
	 * */
	const rids = rightData.value.map(item => item[props.nodeKey])
	setTreeIsDisabled(leftTreeData.value, rids, props.nodeKey, props.defaultProps)
	updateDisabledStatus(leftTreeData.value, props.defaultProps)
	isLeftCheckAllBoxDisabled.value = checkAllDisabled(leftTreeData.value, props.defaultProps)
}
// 去左边
const toLeft = async () => {
	if (props.isToList) {
		// 获取当前右侧选中的数据,没有就return
		const listToLeftIds = rightData.value.filter(item => item.checked).map(item => item[props.nodeKey])
		if (!listToLeftIds.length) return

		// 从右侧去掉选中的数据,并将所有数据的checked设为false,避免由索引变更导致的异常选中
		const unselectedList = rightData.value.filter(item => !item.checked)
		rightData.value.map(item => (item.checked = false))
		rightData.value = unselectedList

		// 恢复选中数据在左侧的可选状态,并清除选中状态
		listToLeftIds.forEach(item => leftTreeRef.value.setChecked(item, false))
		setTreeIsDisabled(leftTreeData.value, listToLeftIds, props.nodeKey, props.defaultProps, false)
		updateDisabledStatus(leftTreeData.value, props.defaultProps)

		checkVal()
		isLeftCheckAllBoxDisabled.value = checkAllDisabled(leftTreeData.value, props.defaultProps)
	} else {
		// 获取当前右侧选中的数据,没有就return
		const treeToLeftIds = getRightTReeCheckedNodeIds()
		if (!treeToLeftIds.length) return

		// 恢复选中数据在左侧的可选状态,并清除选中状态
		setTreeIsDisabled(leftTreeData.value, treeToLeftIds, props.nodeKey, props.defaultProps, false)
		treeToLeftIds.forEach(item => leftTreeRef.value.setChecked(item, false))
		updateDisabledStatus(leftTreeData.value, props.defaultProps)

		checkVal()
		isLeftCheckAllBoxDisabled.value = checkAllDisabled(leftTreeData.value, props.defaultProps)

		rightTreeData.value = []
		rightTreeData.value = getRightTreeData()
		isCheckedAllRight.value = checkedAllTrue(rightTreeRef.value, rightTreeData.value, props.nodeKey, props.defaultProps)
	}
}

// 获取右侧树中选中节点的Ids
const getRightTReeCheckedNodeIds = () => {
	// 返回全部节点填false, false ;返回叶子结点填true,true
	const checkNodeIds = rightTreeRef.value.getCheckedKeys(true)
	if (!checkNodeIds.length) return []

	return checkNodeIds
}

// 左侧数据全选操作(全不选)
const handleLeftAllCheck = () => {
	const leftTree = leftTreeRef.value
	const disabledIds = getDisabledNodeIds(leftTreeData.value, props.nodeKey, props.defaultProps)

	if (isCheckedAllLeft.value) {
		/*
		 * 操作 : 设置全选
		 * 逻辑 : 已经设置了disable的节点无法编辑选中,所以先获取所有设置了disable的节点的ids,然后将所有数据放开disable,设置全部选中,选中后再将ids中的节点设置禁用
		 * */
		setFieldValue(leftTreeData.value, props.defaultProps.disabled, false, props.defaultProps)
		leftTree?.setCheckedNodes(leftTreeData.value)
		setTreeIsDisabled(leftTreeData.value, disabledIds, props.nodeKey, props.defaultProps)
		isCheckedAllLeft.value = true
	} else {
		/*
		 * 操作 : 设置全不选
		 * 逻辑 : 已经设置disabled的节点不应该改变其选中和禁用状态 ,所以先获取所有禁用数据的ids(也就是checked=true的所有当前选中状态的数据),然后取消全部的选中状态,再将ids中的节点设置为选中状态
		 * */
		leftTree?.setCheckedNodes([])
		disabledIds.forEach(item => leftTreeRef.value.setChecked(item, true))
		isCheckedAllLeft.value = false
	}
}
// 左侧树节点checkbox被点击
const handleLeftCheckChange = () => {
	isCheckedAllLeft.value = checkedAllTrue(leftTreeRef.value, leftTreeData.value, props.nodeKey, props.defaultProps)
}

// 右侧树节点checkbox被点击
const handleRightCheckChange = () => {
	isCheckedAllRight.value = checkedAllTrue(rightTreeRef.value, rightTreeData.value, props.nodeKey, props.defaultProps)
}

// 右侧数据全选操作(全不选)
const handleRightAllCheck = () => {
	// list
	setFieldValue(rightData.value, 'checked', isCheckedAllRight.value, props.defaultProps)
	// tree
	rightTreeRef.value.setCheckedNodes(isCheckedAllRight.value ? rightTreeData.value : [])
}

// 返回已选数据给父组件
const checkVal = () => {
	emit('checkVal', props.isToList ? rightData.value : leftTreeRef.value.getCheckedNodes(true))
}

const walkTreeData = (nodes, selectedKeys) => {
	const ret = []
	nodes.forEach((node) => {
		const newNode = { ...node }
		newNode[props.defaultProps.disabled] = false

		delete newNode[props.defaultProps.children]
		node[props.defaultProps.children] && (newNode[props.defaultProps.children] = walkTreeData(node[props.defaultProps.children], selectedKeys))
		if (selectedKeys.includes(newNode[props.nodeKey]) || (newNode[props.defaultProps.children] && newNode[props.defaultProps.children].length)) {
			ret.push(newNode)
		}
	})

	return ret
}

// 获取右侧list结构数据
const getRightListData = () => {
	/*  右侧list结构数据获取 */
	if (!currentLeftUseableNodes.value.length) return []

	const newArr = rightData.value.concat(currentLeftUseableNodes.value)
	const obj: any = {}
	// 去重
	const peon: any = newArr.reduce((cur, next) => {
		obj[next[props.nodeKey]] ? '' : (obj[next[props.nodeKey]] = true && cur.push(next))
		cur.checked = false
		return cur
	}, []) // 设置cur默认类型为数组,并且初始值为空的数组

	return peon
}

// 获取右侧树结构数据
const getRightTreeData = () => {
	if (!leftTreeRef.value || !rightTreeRef.value) return []

	const checkedKeys = leftTreeRef.value.getCheckedKeys(false) // 当前选中节点 key 的数组
	const halfCheckedKeys = leftTreeRef.value.getHalfCheckedKeys() // 目前半选中的节点的 key 所组成的数组
	const allCheckedKeys = halfCheckedKeys.concat(checkedKeys)
	if (allCheckedKeys && allCheckedKeys.length) {
		return walkTreeData(leftTreeData.value, allCheckedKeys)
	} else {
		return []
	}
}

// 获取左侧树当前所选的可进行右移操作的数据
const currentLeftUseableNodes = computed(() => {
	if (!leftTreeRef.value) return []

	// 返回全部节点填false ;返回叶子结点填true
	const checkNodes = leftTreeRef.value.getCheckedNodes(true) // 将返回当前选中节点的节点数组
	if (!checkNodes.length) return []

	// 过滤当前已选,如果没有选择新的数据就return
	const useableNodes = checkNodes.filter(item => !item[props.defaultProps.disabled])
	if (!useableNodes.length) return []

	return useableNodes
})

// 左移按钮disabled计算
const isToLeftBtnDisabled = computed(() => {
	let checkNodes = []
	rightTreeRef.value && (checkNodes = rightTreeRef.value.getCheckedNodes(false, false)) // tree选择的节点
	const listToLeftIds = rightData.value.filter(item => item.checked).map(item => item[props.nodeKey]) // list选择的节点

	return !(listToLeftIds.length || checkNodes.length)
})

// 更新 treeData 中的指定节点,添加子节点
const updateTreeData = (targetNode: any, childNodes: any) => {
	const recursiveUpdate = (nodes: any) => {
		for (const node of nodes) {
			if (node[props.nodeKey] === targetNode[props.nodeKey]) {
				node[props.defaultProps.children] = childNodes // 将子节点添加到目标节点
			} else if (node[props.defaultProps.children]) {
				recursiveUpdate(node[props.defaultProps.children]) // 递归查找目标节点
			}
		}
	}

	if (!Object.keys(leftTreeData.value).length) {
		leftTreeData.value = childNodes
		return
	}

	recursiveUpdate(leftTreeData.value)
}

//  懒加载方法
const handleLoadNode = (node: any, resolve: any) => {
	if (props.lazy) {
		const pid = node.level === 0 ? 0 : node.data[props.nodeKey]
		props
			.loadMethod(pid)
			.then((res: any) => {
				if (res || Array.isArray(res)) {
					// 更新 treeData,确保包含懒加载的节点
					// 在节点展开时,确保 treeData 是最新的完整结构
					resolve(res)
				} else {
					resolve([])
				}
				updateTreeData(node.data, res)
			})
			.catch((err: any) => {
				console.error('Failed to load node data:', err)
				resolve([])
			})
	} else {
		resolve(node.data[props.defaultProps.children] || [])
	}
}

// 监听右侧数据变化,判断右侧全选框是否选中
watch(
	() => rightData.value,
	(newData) => {
		if (!newData || !props.isToList) return
		isCheckedAllRight.value = newData.length && newData.every(item => item.checked)
	},
	{
		deep: true,
		immediate: true,
	},
)

// 初始化操作,将传参的默认选中节点传递并显示到右侧
watch(
	() => props.defaultSelectionKeys,
	(newKeys) => {
		if (props.lazy && props.loadMethod) return
		if (!newKeys?.length) return

		nextTick(async () => {
			// 设置目前选中的节点
			await leftTreeRef.value.setCheckedKeys(newKeys)
			toRight()
		})
	},
	{
		deep: true,
		immediate: true,
	},
)

// 初始化操作,将传参的默认选中节点传递并显示到右侧
watch(
	() => props.leftData,
	(newData) => {
		// 如果是懒加载,并且有loadMethod方法,直接return
		if (props.lazy && props.loadMethod) return
		// 没有数据就return
		if (!newData?.length) return
		leftTreeData.value = deepClone(newData)
		setFieldValue(leftTreeData.value, props.defaultProps.disabled, false, props.defaultProps)
	},
	{
		deep: true,
		immediate: true,
	},
)

watch(leftFilterText, (val) => {
	leftTreeRef.value!.filter(val)
})
</script>

<template>
	<div class="zt-tree-transfer">
		<!-- 左边 -->
		<div class="left-content">
			<div class="list">
				<div class="left_lowline">
					<el-checkbox
						v-model="isCheckedAllLeft"
						:disabled="isLeftCheckAllBoxDisabled"
						label=""
						size="large"
						@change="handleLeftAllCheck"
					/>
					<p class="left_title">
						{{ leftTitle }}
					</p>
				</div>
				<!-- 搜索 -->
				<div class="left_input">
					<el-input
						v-model="leftFilterText"
						:prefix-icon="Search"
						class="w-50 m-2"
						placeholder="搜索"
						clearable
					/>
				</div>
				<div class="left-tree">
					<el-tree
						ref="leftTreeRef"
						v-slot="{ node, data }"
						:check-on-click-node="checkOnClickNode"
						:data="leftTreeData"
						:default-expand-all="defaultExpandAll"
						:expand-on-click-node="expandOnClickNode"
						:filter-node-method="filterLeftNode"
						:lazy="lazy"
						:load="handleLoadNode"
						:node-key="nodeKey"
						:props="defaultProps"
						highlight-current
						show-checkbox
						@check-change="handleLeftCheckChange"
					/>
				</div>
			</div>
		</div>
		<!-- 中间按钮 -->
		<div class="btn-div">
			<div class="btn-item" @click="toRight()">
				<el-button
					:disabled="!currentLeftUseableNodes.length"
					:icon="ArrowRight"
					size="large"
					type="primary"
				/>
			</div>
			<div class="btn-item" @click="toLeft()">
				<el-button
					:disabled="isToLeftBtnDisabled"
					:icon="ArrowLeft"
					size="large"
					type="primary"
				/>
			</div>
		</div>
		<!-- 右边 -->
		<div class="righ-content">
			<div class="list">
				<div class="left_lowline">
					<el-checkbox
						v-model="isCheckedAllRight"
						:disabled="isRightCheckAllBoxDisabled"
						label=""
						size="large"
						@change="handleRightAllCheck"
					/>
					<p class="left_title">
						{{ rightTitle }}
					</p>
				</div>
				<!-- 搜索 -->
				<div class="left_input">
					<el-input
						v-model="rightFilterText"
						:prefix-icon="Search"
						class="w-50 m-2"
						placeholder="搜索"
						clearable
					/>
				</div>

				<!--    右侧数据展示格式为list时    -->
				<div v-if="isToList">
					<!--   根据[props.nodeKey]排序  ;  根据rightFilterText进行过滤显示    -->
					<div
						v-for="(item, index) in sortRightListByKey().filter((item) => item[defaultProps.label].includes(rightFilterText))"
						v-if="sortRightListByKey().filter((item) => item[defaultProps.label].includes(rightFilterText)).length"
						:key="index"
						class="right_item"
					>
						<!-- 检查是否有名为 "right-item" 的插槽内容 -->
						<slot
							v-if="$slots['right-item']"
							:index="index"
							:item="item"
							name="right-item"
						></slot>
						<!-- 如果没有,则显示默认内容 -->
						<div v-else>
							<el-checkbox
								v-model="item.checked"
								:false-label="false"
								:true-label="true"
								:value="item[nodeKey]"
							>
								{{ item[defaultProps.label] }}
							</el-checkbox>
						</div>
					</div>

					<div v-else style="padding: 10px">
						<el-text type="info">
							暂无数据
						</el-text>
					</div>
				</div>

				<!--    右侧数据展示格式为tree时    -->
				<div v-else class="right-tree">
					<el-tree
						ref="rightTreeRef"
						v-slot="{ node, data }"
						:check-on-click-node="checkOnClickNode"
						:data="rightTreeData"
						:default-expand-all="defaultExpandAll"
						:expand-on-click-node="expandOnClickNode"
						:filter-node-method="filterRightNode"
						:node-key="nodeKey"
						:props="defaultProps"
						highlight-current
						show-checkbox
						@check-change="handleRightCheckChange"
					/>
				</div>
			</div>
		</div>
	</div>
</template>

<style lang="less" scoped>
.zt-tree-transfer {
	display: flex;
	height: 500px;
	width: 800px;
	box-sizing: border-box;

	.btn-div {
		flex: 1;
		height: 60%;
		margin: auto;
		display: flex;
		flex-direction: column;
		justify-content: space-evenly;
		align-items: center;

		.btn-item {
			:deep(svg),
			:deep(.el-icon) {
				height: 1.6em !important;
				width: 1.6em !important;
			}
		}
	}

	.left-content {
		width: 45%;
		border: 1px solid #dcdfe6;
		box-sizing: border-box;
		padding: 5px 10px;

		.list {
			width: 100%;
			height: 100%;
			display: flex;
			flex-direction: column;
			overflow: hidden;

			.left-tree {
				width: calc(100% - 5px);
				height: 100%;
				overflow: auto;
				margin-top: 10px;
				padding-right: 5px;
			}
		}
	}

	.righ-content {
		box-sizing: border-box;
		border: 1px solid #dcdfe6;
		padding: 5px 10px;
		width: 45%;
		overflow: auto;

		.right_item {
			text-align: left;
		}

		.list {
			height: 100%;
			display: flex;
			flex-direction: column;
		}
	}

	.left_lowline {
		display: flex;
		align-items: center;
	}

	.right_lowline {
		display: flex;
		align-items: center;
	}

	:deep(.el-input__wrapper) {
		position: relative;

		.el-input__inner {
			padding-right: 18px;
		}

		.el-input__suffix {
			position: absolute;
			right: 8px;
			top: 50%;
			transform: translateY(-50%);
		}
	}

	// 滚动条宽度
	::-webkit-scrollbar {
		width: 6px;
		height: 6px;
	}

	// 滚动条轨道
	::-webkit-scrollbar-track {
		background: rgb(239, 239, 239);
		border-radius: 2px;
	}

	// 小滑块
	::-webkit-scrollbar-thumb {
		background: #40a0ff49;
		border-radius: 2px;
	}

	::-webkit-scrollbar-thumb:hover {
		background: #40a0ff;
	}

	:deep(.el-button:focus) {
		outline: none;
	}

	:deep(.el-tree) {
		display: inline-block;
		min-width: 100%;

		.el-tree-node__content {
			//margin-right: 5px;
		}
	}
}
</style>
相关推荐
天天进步201514 分钟前
Vue+Springboot用Websocket实现协同编辑
vue.js·spring boot·websocket
虾球xz25 分钟前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇31 分钟前
HTML常用表格与标签
前端·html
疯狂的沙粒34 分钟前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员1 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐1 小时前
前端图像处理(一)
前端
程序猿阿伟1 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒1 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪1 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背1 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript