双向列表可视化连接组件开发简单记录

364 阅读4分钟

本文是基于leader-line-vue+vuedraggable实现拖拽和连线

准备工作

npm install vuedraggable@next leader-line-vue

一、组件初始化阶段

  1. 基础结构搭建html
<template>
  <div class="dual-connection">
    <!-- 左侧列表 -->
    <div class="list-container">
      <h3>源数据 ({{ leftList.length }})</h3>
      <draggable
        v-model="leftList"
        group="connection"
        item-key="id"
        @end="updateLines"
      >
        <template #item="{ element }">
          <div
            class="item-card"
            :ref="(el) => setNodeRef(el, element.id)"
            @click="handleSelect(element.id, 'left')"
            :class="{
              active: activeSelection.left === element.id,
              connected: isConnected(element.id),
            }"
          >
            {{ element.name }}
            <span v-if="getPartnerId(element.id)" class="partner-badge">
              {{ getPartnerName(getPartnerId(element.id)) }}
            </span>
          </div>
        </template>
      </draggable>
    </div>

    <!-- 控制中心 -->
    <div class="control-center">
      <button @click="connectSelected" :disabled="!canConnect">建立连接</button>
      <button @click="disconnectSelected" :disabled="!canDisconnect">
        解除连接
      </button>
      <div class="connection-status">
        已连接: {{ connectedCount }} /
        {{ Math.min(leftList.length, rightList.length) }}
      </div>
    </div>

    <!-- 右侧列表 -->
    <div class="list-container">
      <h3>目标数据 ({{ rightList.length }})</h3>
      <draggable
        v-model="rightList"
        group="connection"
        item-key="id"
        @end="updateLines"
      >
        <template #item="{ element }">
          <div
            class="item-card"
            :ref="(el) => setNodeRef(el, element.id)"
            @click="handleSelect(element.id, 'right')"
            :class="{
              active: activeSelection.right === element.id,
              connected: isConnected(element.id),
            }"
          >
            {{ element.name }}
            <span v-if="getPartnerId(element.id)" class="partner-badge">
              {{ getPartnerName(getPartnerId(element.id)) }}
            </span>
          </div>
        </template>
      </draggable>
    </div>
  </div>
</template>

二、核心功能实现

<script> 
import { ref, computed, onMounted, onBeforeUnmount } from "vue"; 
import draggable from "vuedraggable"; 
import LeaderLine from "leader-line-vue";

export default { 
 components: { draggable }, // 注册draggable组件为当前组件的子组件
 setup() { 
 // 生成带类型的测试数据
 const generateItems = (prefix, count, type) => { // 定义一个函数,用于生成指定数量、类型、前缀的测试数据
 return Array.from({ length: count }, (_, i) => ({ // 使用Array.from生成一个数组,数组元素由回调函数生成
 id: `${prefix}-${type}-${i + }`, // 设置元素的id属性
 name: `${type}${i + }`, // 设置元素的name属性
 type: type, // 设置元素的type属性
 value: Math.random().toString().slice(, ), // 设置元素的value属性,为一个随机字符串
 })); 
 }; 

 // 状态管理
 const leftList = ref( // 创建一个响应式引用,存储左侧列表数据
 ...generateItems("L", , "设备"), // 生成个设备类型的测试数据
 ...generateItems("L", , "用户"), // 生成个用户类型的测试数据
 ]); 
 const rightList = ref( // 创建一个响应式引用,存储右侧列表数据
 ...generateItems("R", , "位置"), // 生成个位置类型的测试数据
 ...generateItems("R", , "部门"), // 生成个部门类型的测试数据
 ]); 
 const nodeRefs = ref({}); // 创建一个响应式引用,用于存储节点元素的引用
 const activeSelection = ref({ left: null, right: null }); // 创建一个响应式引用,用于存储当前选中的左侧和右侧项
 const connectionMap = ref(new Map()); // 创建一个响应式引用,用于存储连线映射关系(左侧项ID到右侧项ID)
 const reverseConnectionMap = ref(new Map()); // 创建一个响应式引用,用于存储反向连线映射关系(右侧项ID到左侧项ID)
 const lineInstances = ref(new Map()); // 创建一个响应式引用,用于存储连线实例

 // 设置节点引用
 const setNodeRef = (el, id) => { // 定义一个函数,用于设置节点元素的引用
 if (el) nodeRefs.valueid = el; // 如果传入的元素el存在,则将其存储到nodeRefs中,以id为键
 }; 

 // 创建连线
 const createConnection = (sourceId, targetId) => { // 定义一个函数,用于创建从sourceId到targetId的连线
 // 检查是否已存在连接
 if (connectionMap.value.has(sourceId) || reverseConnectionMap.value.has(targetId)) { // 如果sourceId已连接或targetId已有反向连接,则返回false
 return false; 
 } 
 const sourceEl = nodeRefs.valuesourceId]; // 获取sourceId对应的元素
 const targetEl = nodeRefs.valuetargetId]; // 获取targetId对应的元素
 if (!sourceEl || !targetEl) return false; // 如果sourceEl或targetEl不存在,则返回false
 // 创建连线实例
 const line = LeaderLine.setLine(sourceEl, targetEl, { // 使用LeaderLine的setLine方法创建连线
 color: `hsl(${Math.random() * }, %, %)`, // 设置连线的颜色为随机色
 path: "fluid", // 设置连线的路径为流体路径
 startSocket: "right", // 设置起点插座位置为右侧
 endSocket: "left", // 设置终点插座位置为左侧
 size: , // 设置连线粗细为
 startSocketGravity: , ], // 设置起点插座的重力方向及强度
 endSocketGravity: [-, ], // 设置终点插座的重力方向及强度
 dash: { animation: true }, // 设置连线为虚线并添加流动动画效果
 }); 
 // 更新连接映射
 connectionMap.value.set(sourceId, targetId); // 在connectionMap中添加sourceId到targetId的映射
 reverseConnectionMap.value.set(targetId, sourceId); // 在reverseConnectionMap中添加targetId到sourceId的反向映射
 lineInstances.value.set(`${sourceId}-${targetId}`, line); // 在lineInstances中添加连线的实例
 return true; // 返回true表示连线创建成功
 }; 

 // 断开连接
 const disconnect = (sourceId, targetId) => { // 定义一个函数,用于断开从sourceId到targetId的连接
 const connectionKey = `${sourceId}-${targetId}`; // 生成连接的键
 const line = lineInstances.value.get(connectionKey); // 从lineInstances中获取连线实例
 if (line) { // 如果连线实例存在
 line.remove(); // 移除连线
 lineInstances.value.delete(connectionKey); // 从lineInstances中删除连线实例
 } 
 connectionMap.value.delete(sourceId); // 从connectionMap中删除sourceId的映射
 reverseConnectionMap.value.delete(targetId); // 从reverseConnectionMap中删除targetId的反向映射
 }; 

 // 更新所有连线位置
 const updateLines = () => { // 定义一个函数,用于更新所有连线的位置
 requestAnimationFrame(() => { // 使用requestAnimationFrame来优化性能
 lineInstances.value.forEach((line) => line.position()); // 遍历lineInstances,调用每个连线的position方法更新位置
 }); 
 }; 

 // 处理选择项
 const handleSelect = (id, side) => { // 定义一个函数,用于处理选择项
 activeSelection.valueside = id; // 将选中的项ID存储到activeSelection中,side为'left'或'right'
 }; 

 // 连接选中项
 const connectSelected = () => { // 定义一个函数,用于连接选中的左侧和右侧项
 if (activeSelection.value.left && activeSelection.value.right) { // 如果左侧和右侧都有选中的项
 const success = createConnection(activeSelection.value.left, activeSelection.value.right); // 尝试创建连线
 if (success) { // 如果创建成功
 activeSelection.value = { left: null, right: null }; // 清空选中的项
 } 
 } 
 }; 

 // 断开选中项
 const disconnectSelected = () => { // 定义一个函数,用于断开选中的项的连接
 if (activeSelection.value.left && connectionMap.value.has(activeSelection.value.left)) { // 如果左侧有选中的项且已连接
 const targetId = connectionMap.value.get(activeSelection.value.left); // 获取连接的右侧项ID
 disconnect(activeSelection.value.left, targetId); // 断开连接
 } else if (activeSelection.value.right && reverseConnectionMap.value.has(activeSelection.value.right)) { // 如果右侧有选中的项且有反向连接
 const sourceId = reverseConnectionMap.value.get(activeSelection.value.right); // 获取连接的左侧项ID
 disconnect(sourceId, activeSelection.value.right); // 断开连接
 } 
 activeSelection.value = { left: null, right: null }; // 清空选中的项
 }; 

 // 获取伙伴ID
 const getPartnerId = (id) => { // 定义一个函数,用于获取与指定ID相连的伙伴ID
 return connectionMap.value.get(id) || reverseConnectionMap.value.get(id); // 从connectionMap或reverseConnectionMap中获取伙伴ID
 }; 

 // 获取伙伴名称
 const getPartnerName = (id) => { // 定义一个函数,用于获取指定ID的伙伴名称
 const allItems = [...leftList.value, ...rightList.value]; // 合并左右列表数据
 const item = allItems.find((item) => item.id === id); // 查找指定ID的项
 return item ? item.name : ""; // 返回项的名称或空字符串
 }; 

 // 检查是否已连接
 const isConnected = (id) => { // 定义一个函数,用于检查指定ID是否已连接
 return connectionMap.value.has(id) || reverseConnectionMap.value.has(id); // 从connectionMap或reverseConnectionMap中检查是否已连接
 }; 

 // 计算属性
 const canConnect = computed(() => { // 定义一个计算属性,用于判断是否可以连接
 return activeSelection.value.left && activeSelection.value.right && !isConnected(activeSelection.value.left) && !isConnected(activeSelection.value.right); // 判断条件为左侧和右侧都有选中的项且都未连接
 }); 

 const canDisconnect = computed(() => { // 定义一个计算属性,用于判断是否可以断开连接
 return (activeSelection.value.left && isConnected(activeSelection.value.left)) || (activeSelection.value.right && isConnected(activeSelection.value.right)); // 判断条件为左侧或右侧有选中的项且已连接
 }); 

 const connectedCount = computed(() => { // 定义一个计算属性,用于获取已连接的数量
 return connectionMap.value

三、样式优化

<style>
.dual-connection {
  display: flex;
  height: 85vh;
  padding: 20px;
  gap: 20px;
  background: #f5f7fa;
}

.list-container {
  flex: 1;
  border: 1px solid #e1e4e8;
  border-radius: 8px;
  padding: 15px;
  overflow-y: auto;
  background: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.item-card {
  padding: 12px 15px;
  margin: 8px 0;
  background: #f8f9fa;
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  transition: all 0.2s;
  border-left: 3px solid transparent;
}

.item-card:hover {
  background: #e9ecef;
}

.item-card.active {
  background: #e7f1ff;
  border-left-color: #4d7cfe;
}

.item-card.connected {
  background: #f0f7ff;
  border-left-color: #3a6bfd;
}

.partner-badge {
  position: absolute;
  right: 10px;
  background: #495057;
  color: white;
  border-radius: 4px;
  padding: 2px 6px;
  font-size: 12px;
}

.control-center {
  width: 160px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 15px 0;
}

button {
  padding: 10px;
  border: none;
  border-radius: 6px;
  background: #4d7cfe;
  color: white;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.2s;
}

button:hover {
  background: #3a6bfd;
}

button:disabled {
  background: #adb5bd;
  cursor: not-allowed;
}

.connection-status {
  margin-top: 10px;
  padding: 8px;
  background: #f1f3f5;
  border-radius: 6px;
  text-align: center;
  font-size: 13px;
}

h3 {
  margin: 0 0 15px 0;
  padding-bottom: 10px;
  border-bottom: 1px solid #e1e4e8;
  color: #24292e;
}
</style>

效果如下:

1747320513386.jpg