javascript
<template>
<div class="dual-tree-container">
<!-- 左侧树 -->
<div class="tree-container">
<div class="tree-title">{{ leftTreeTitle }}</div>
<el-tree
ref="leftTreeRef"
:data="leftTreeData"
:props="treeProps"
node-key="id"
:highlight-current="true"
:expand-on-click-node="false"
@node-click="handleLeftNodeClick"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span>{{ node.label }}</span>
<span v-if="data.matchedId" class="matched-indicator">✅ 已关联</span>
</span>
</template>
</el-tree>
</div>
<!-- 中间关联操作区域 -->
<div class="link-operation">
<el-button
type="primary"
:disabled="!selectedLeftNode || !selectedRightNode || isLinked"
@click="linkNodes"
>
建立关联
</el-button>
<el-button
type="danger"
:disabled="!selectedLinkedPair"
@click="unlinkNodes"
>
解除关联
</el-button>
<div v-if="selectedLeftNode && selectedRightNode" class="current-selection">
<p>左侧选择: {{ selectedLeftNode?.label }}</p>
<p>右侧选择: {{ selectedRightNode?.label }}</p>
</div>
</div>
<!-- 右侧树 -->
<div class="tree-container">
<div class="tree-title">{{ rightTreeTitle }}</div>
<el-tree
ref="rightTreeRef"
:data="rightTreeData"
:props="treeProps"
node-key="id"
:highlight-current="true"
:expand-on-click-node="false"
@node-click="handleRightNodeClick"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span>{{ node.label }}</span>
<span v-if="data.matchedId" class="matched-indicator">✅ 已关联</span>
</span>
</template>
</el-tree>
</div>
<!-- 关联关系显示 -->
<div v-if="showLinkList" class="link-list">
<h3>已建立的关联关系</h3>
<el-table :data="linkedPairs" style="width: 100%">
<el-table-column prop="leftLabel" :label="leftTreeTitle" />
<el-table-column prop="rightLabel" :label="rightTreeTitle" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
type="danger"
size="small"
@click="removeLink(row.leftId)"
>
解除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
// 左侧树数据
leftData: {
type: Array,
required: true,
default: () => []
},
// 右侧树数据
rightData: {
type: Array,
required: true,
default: () => []
},
// 树配置
treeProps: {
type: Object,
default: () => ({
children: 'children',
label: 'label'
})
},
// 左侧树标题
leftTreeTitle: {
type: String,
default: '左侧树'
},
// 右侧树标题
rightTreeTitle: {
type: String,
default: '右侧树'
},
// 是否显示关联列表
showLinkList: {
type: Boolean,
default: true
},
// 初始关联关系 [{ leftId: 'xxx', rightId: 'xxx' }]
initialLinks: {
type: Array,
default: () => []
}
})
const emit = defineEmits([
'update:links', // 关联关系更新
'link-change', // 关联关系变更
'node-link', // 建立关联
'node-unlink', // 解除关联
'left-click', // 左侧节点点击
'right-click' // 右侧节点点击
])
// 内部使用的数据副本(深拷贝避免修改原数据)
const leftTreeData = ref(JSON.parse(JSON.stringify(props.leftData)))
const rightTreeData = ref(JSON.parse(JSON.stringify(props.rightData)))
// 存储关联关系的Map
const linkMap = reactive(new Map())
// 当前选择的节点
const selectedLeftNode = ref(null)
const selectedRightNode = ref(null)
// 获取Tree实例
const leftTreeRef = ref()
const rightTreeRef = ref()
// 已关联的对
const linkedPairs = computed(() => {
return Array.from(linkMap.entries()).map(([leftId, rightId]) => {
const leftNode = findNodeById(leftTreeData.value, leftId)
const rightNode = findNodeById(rightTreeData.value, rightId)
return {
leftId,
leftLabel: leftNode?.label || leftId,
rightId,
rightLabel: rightNode?.label || rightId
}
})
})
// 当前选择的两个节点是否已经关联
const isLinked = computed(() => {
if (!selectedLeftNode.value || !selectedRightNode.value) return false
const linkedRightId = linkMap.get(selectedLeftNode.value.id)
return linkedRightId === selectedRightNode.value.id
})
// 当前选择的关联对
const selectedLinkedPair = computed(() => {
if (!selectedLeftNode.value || !selectedRightNode.value) return null
return linkedPairs.value.find(
pair => pair.leftId === selectedLeftNode.value.id &&
pair.rightId === selectedRightNode.value.id
)
})
// 查找节点
function findNodeById(nodes, id) {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNodeById(node.children, id)
if (found) return found
}
}
return null
}
// 更新树的显示状态
function updateTreeDisplay() {
// 清除所有节点的matchedId标记
const clearMatchedId = (nodes) => {
nodes.forEach(node => {
delete node.matchedId
if (node.children) {
clearMatchedId(node.children)
}
})
}
clearMatchedId(leftTreeData.value)
clearMatchedId(rightTreeData.value)
// 为已关联的节点添加标记
linkMap.forEach((rightId, leftId) => {
const leftNode = findNodeById(leftTreeData.value, leftId)
const rightNode = findNodeById(rightTreeData.value, rightId)
if (leftNode) leftNode.matchedId = rightId
if (rightNode) rightNode.matchedId = leftId
})
}
// 左侧节点点击
function handleLeftNodeClick(data, node) {
// 取消之前的选择
if (selectedLeftNode.value?.id === data.id) {
selectedLeftNode.value = null
leftTreeRef.value?.setCurrentKey(null)
return
}
selectedLeftNode.value = data
emit('left-click', data, node)
}
// 右侧节点点击
function handleRightNodeClick(data, node) {
// 取消之前的选择
if (selectedRightNode.value?.id === data.id) {
selectedRightNode.value = null
rightTreeRef.value?.setCurrentKey(null)
return
}
selectedRightNode.value = data
emit('right-click', data, node)
}
// 建立关联
function linkNodes() {
if (!selectedLeftNode.value || !selectedRightNode.value) return
// 检查左侧节点是否已有关联
if (linkMap.has(selectedLeftNode.value.id)) {
ElMessage.warning('左侧节点已有关联,请先解除关联')
return
}
// 检查右侧节点是否已有关联
const isRightLinked = Array.from(linkMap.values())
.includes(selectedRightNode.value.id)
if (isRightLinked) {
ElMessage.warning('右侧节点已有关联,请先解除关联')
return
}
// 建立关联
linkMap.set(selectedLeftNode.value.id, selectedRightNode.value.id)
updateTreeDisplay()
// 触发事件
emit('node-link', {
leftId: selectedLeftNode.value.id,
leftNode: selectedLeftNode.value,
rightId: selectedRightNode.value.id,
rightNode: selectedRightNode.value
})
emitLinkChange()
ElMessage.success('关联建立成功')
// 清空选择
clearSelections()
}
// 解除当前选择的关联
function unlinkNodes() {
if (!selectedLeftNode.value || !selectedRightNode.value) return
const leftId = selectedLeftNode.value.id
const rightId = selectedRightNode.value.id
linkMap.delete(leftId)
updateTreeDisplay()
// 触发事件
emit('node-unlink', { leftId, rightId })
emitLinkChange()
ElMessage.success('关联已解除')
// 清空选择
clearSelections()
}
// 移除指定关联
function removeLink(leftId) {
const rightId = linkMap.get(leftId)
linkMap.delete(leftId)
updateTreeDisplay()
// 触发事件
emit('node-unlink', { leftId, rightId })
emitLinkChange()
ElMessage.success('关联已解除')
}
// 清空选择
function clearSelections() {
selectedLeftNode.value = null
selectedRightNode.value = null
leftTreeRef.value?.setCurrentKey(null)
rightTreeRef.value?.setCurrentKey(null)
}
// 触发关联关系变更事件
function emitLinkChange() {
const links = Array.from(linkMap.entries()).map(([leftId, rightId]) => ({
leftId,
rightId
}))
emit('update:links', links)
emit('link-change', links)
}
// 设置关联关系
function setLinks(links) {
linkMap.clear()
links.forEach(link => {
linkMap.set(link.leftId, link.rightId)
})
updateTreeDisplay()
}
// 获取当前所有关联关系
function getLinks() {
return Array.from(linkMap.entries()).map(([leftId, rightId]) => ({
leftId,
rightId
}))
}
// 清除所有关联
function clearAllLinks() {
linkMap.clear()
updateTreeDisplay()
emitLinkChange()
ElMessage.success('已清除所有关联')
}
// 初始化关联关系
watch(() => props.initialLinks, (newLinks) => {
setLinks(newLinks)
}, { immediate: true })
// 监听数据源变化
watch(() => props.leftData, (newData) => {
leftTreeData.value = JSON.parse(JSON.stringify(newData))
updateTreeDisplay()
}, { deep: true })
watch(() => props.rightData, (newData) => {
rightTreeData.value = JSON.parse(JSON.stringify(newData))
updateTreeDisplay()
}, { deep: true })
// 暴露给父组件的方法
defineExpose({
setLinks,
getLinks,
clearAllLinks,
clearSelections,
updateTreeDisplay
})
</script>
<style scoped>
.dual-tree-container {
display: flex;
gap: 20px;
padding: 20px;
flex-wrap: wrap;
}
.tree-container {
flex: 1;
min-width: 300px;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 10px;
min-height: 400px;
}
.tree-title {
text-align: center;
font-weight: bold;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
}
.link-operation {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
min-width: 150px;
}
.current-selection {
margin-top: 20px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
.matched-indicator {
font-size: 12px;
color: #67c23a;
margin-left: 10px;
}
.link-list {
width: 100%;
margin-top: 20px;
padding: 20px;
border-top: 1px solid #e4e7ed;
}
</style>
使用示例:
父组件中使用:
javascript
<template>
<div class="app-container">
<h1>双树关联组件示例</h1>
<!-- 使用双树关联组件 -->
<DualTreeLink
v-model:links="currentLinks"
:left-data="leftTreeData"
:right-data="rightTreeData"
:initial-links="initialLinks"
left-tree-title="部门"
right-tree-title="负责人"
@link-change="handleLinkChange"
@node-link="handleNodeLink"
@node-unlink="handleNodeUnlink"
/>
<!-- 操作按钮 -->
<div class="actions">
<el-button @click="saveLinks">保存关联关系</el-button>
<el-button @click="resetLinks">重置关联</el-button>
<el-button @click="logCurrentLinks">打印当前关联</el-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import DualTreeLink from './components/DualTreeLink.vue'
import { ElMessage } from 'element-plus'
// 左侧树数据(部门)
const leftTreeData = ref([
{
id: 'dept-1',
label: '技术部',
children: [
{ id: 'dept-1-1', label: '前端组' },
{ id: 'dept-1-2', label: '后端组' }
]
},
{
id: 'dept-2',
label: '市场部',
children: [
{ id: 'dept-2-1', label: '市场策划' },
{ id: 'dept-2-2', label: '品牌推广' }
]
},
{
id: 'dept-3',
label: '人事部',
children: [
{ id: 'dept-3-1', label: '招聘组' },
{ id: 'dept-3-2', label: '培训组' }
]
}
])
// 右侧树数据(负责人)
const rightTreeData = ref([
{
id: 'user-1',
label: '张三',
children: [
{ id: 'user-1-1', label: '李四' },
{ id: 'user-1-2', label: '王五' }
]
},
{
id: 'user-2',
label: '赵六',
children: [
{ id: 'user-2-1', label: '钱七' },
{ id: 'user-2-2', label: '孙八' }
]
}
])
// 初始关联关系
const initialLinks = ref([
{ leftId: 'dept-1-1', rightId: 'user-1-1' },
{ leftId: 'dept-2-2', rightId: 'user-2-2' }
])
// 当前关联关系(双向绑定)
const currentLinks = ref([])
// 处理关联关系变更
const handleLinkChange = (links) => {
console.log('关联关系变更:', links)
currentLinks.value = links
}
// 处理节点关联
const handleNodeLink = ({ leftId, leftNode, rightId, rightNode }) => {
console.log(`建立关联: ${leftNode.label} ↔ ${rightNode.label}`)
ElMessage.success(`${leftNode.label} 与 ${rightNode.label} 关联成功`)
}
// 处理节点解除关联
const handleNodeUnlink = ({ leftId, rightId }) => {
console.log(`解除关联: ${leftId} ↔ ${rightId}`)
ElMessage.info('关联已解除')
}
// 保存关联关系
const saveLinks = () => {
console.log('保存关联关系:', currentLinks.value)
ElMessage.success('保存成功')
}
// 重置关联
const resetLinks = () => {
currentLinks.value = [...initialLinks.value]
ElMessage.success('已重置为初始关联关系')
}
// 打印当前关联
const logCurrentLinks = () => {
console.log('当前关联关系:', currentLinks.value)
}
</script>
<style scoped>
.app-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.actions {
margin-top: 20px;
text-align: center;
}
</style>
组件API文档:
Props:
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| leftData | 左侧树数据 | Array | [] |
| rightData | 右侧树数据 | Array | [] |
| treeProps | 树配置 | Object | { children: 'children', label: 'label' } |
| leftTreeTitle | 左侧树标题 | String | '左侧树' |
| rightTreeTitle | 右侧树标题 | String | '右侧树' |
| showLinkList | 是否显示关联列表 | Boolean | true |
| initialLinks | 初始关联关系 | Array | [] |
| v-model:links | 双向绑定的关联关系 | Array | - |
Events:
| 事件名 | 说明 | 回调参数 |
|---|---|---|
| update:links | 关联关系更新时触发 | (links: Array) |
| link-change | 关联关系变更时触发 | (links: Array) |
| node-link | 建立关联时触发 | ({ leftId, leftNode, rightId, rightNode }) |
| node-unlink | 解除关联时触发 | ({ leftId, rightId }) |
| left-click | 左侧节点点击时触发 | (data: Object, node: Object) |
| right-click | 右侧节点点击时触发 | (data: Object, node: Object) |
Exposed Methods:
| 方法名 | 说明 | 参数 |
|---|---|---|
| setLinks | 设置关联关系 | (links: Array) |
| getLinks | 获取当前所有关联关系 | - |
| clearAllLinks | 清除所有关联 | - |
| clearSelections | 清空当前选择 | - |
| updateTreeDisplay | 更新树显示状态 | - |
这个封装后的组件具有以下特点:
-
完全可复用:通过props传递数据,通过emit回传事件
-
功能完整:包含所有必要的操作和事件
-
双向绑定:支持v-model绑定关联关系
-
类型安全:清晰的props和events定义
-
方法暴露:通过defineExpose暴露方法供父组件调用
-
响应式更新:监听数据源变化并自动更新