实现一个左右树形结构一对一关联的组件。这个方案使用两个el-tree组件,并实现它们之间的互相关联选择。

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 更新树显示状态 -

这个封装后的组件具有以下特点:

  1. 完全可复用:通过props传递数据,通过emit回传事件

  2. 功能完整:包含所有必要的操作和事件

  3. 双向绑定:支持v-model绑定关联关系

  4. 类型安全:清晰的props和events定义

  5. 方法暴露:通过defineExpose暴露方法供父组件调用

  6. 响应式更新:监听数据源变化并自动更新

相关推荐
一 乐1 小时前
旅游出行|基于Springboot+Vue的旅游出行管理系统设计与实现(源码+数据库+文档)
前端·数据库·vue.js·spring boot·后端·旅游
我看刑1 小时前
【已解决】el-date-picker type=“datetime“限制(动态)可选时间范围,精确到分钟!!!
前端·javascript·vue.js
周周爱喝粥呀2 小时前
【基础】Three.js 实现 3D 字体加载与 Matcap 金属质感效果(附案例代码)
前端·javascript·vue.js·3d
克喵的水银蛇2 小时前
Flutter 通用输入框封装实战:带校验 / 清除 / 密码切换的 InputWidget
前端·javascript·flutter
我命由我123453 小时前
微信小程序开发 - 为 tap 事件的处理函数传递数据
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
1024肥宅9 小时前
JavaScript 拷贝全解析:从浅拷贝到深拷贝的完整指南
前端·javascript·ecmascript 6
欧阳天风9 小时前
js实现鼠标横向滚动
开发语言·前端·javascript
局i10 小时前
Vue 指令详解:v-for、v-if、v-show 与 {{}} 的妙用
前端·javascript·vue.js
꒰ঌ小武໒꒱11 小时前
RuoYi-Vue 前端环境搭建与部署完整教程
前端·javascript·vue.js·nginx