Vue 树形拖拽组件 TreeChart

112 阅读7分钟

组件概述

TreeChart 是一款基于vue3二次开源的的树形结构可视化组件,专为展示层级关系数据而设计。该组件通过直观的图形界面和丰富的交互功能,为用户提供了高效的数据组织和展示方案。

源代码地址:blog.csdn.net/qq_45424679…

核心功能亮点

  1. 层级结构可视化

    • 支持多级嵌套节点展示
    • 提供展开/折叠功能,便于查看复杂结构
    • 自动计算节点布局,确保视觉效果清晰
    • 组件采用递归方式渲染多层级节点结构
  2. 智能拖拽交互

    • 实现完整的拖拽交互流程(dragStart/drop)
    • 内置防错机制,防止父子节点循环引用
    • 通过递归算法查找和更新节点位置
  3. 多样化样式定制

    • 提供两种基础节点样式(目标/漫延)
    • 支持水平和垂直两种布局方式
    • 丰富的CSS类名,便于扩展样式
  4. 数据响应式更新

    • 自动监听数据变化
    • 递归处理节点属性
    • 提供默认值处理机制

应用价值

  1. 提升用户体验

    • 直观展示复杂层级关系
    • 简化数据操作流程
    • 增强用户对数据结构的理解
  2. 提高开发效率

    • 开箱即用的解决方案
    • 简洁的API设计
    • 灵活的扩展接口
  3. 业务场景适配

    • 组织结构管理
    • 知识图谱构建
    • 流程关系展示
    • 系统架构可视化

技术实现亮点

  1. 数据结构处理

    • 使用深度优先遍历处理节点树
    • 通过watch监听实现数据响应式
    • 采用JSON序列化传递节点信息
  2. 交互设计

    • 拖拽时传递完整节点数据
    • 精确查找源节点在树中的实际引用
    • 提供详细的调试日志输出

应用场景建议

  1. 组织架构图

    • 可视化展示公司部门层级关系
    • 支持动态调整组织架构
  2. 业务流程图

    • 展示业务流程间的关联关系
    • 支持自由重组业务节点
  3. 项目管理

    • 构建项目任务分解结构
    • 通过拖拽调整任务关系

使用注意事项

  1. 数据格式需包含label/id属性
  2. 根节点需保持引用一致
  3. 节点操作会直接修改原始数据
  4. 复杂数据结构可能影响性能
<template>
  <table v-if="treeData.label">
    <tr>
      <td
        :colspan="Array.isArray(treeData.children) ? treeData.children.length * 2 : 1"
        :class="{
          parentLevel: Array.isArray(treeData.children) && treeData.children.length,
          extend: Array.isArray(treeData.children) && treeData.children.length && treeData.extend
        }"
      >
        <div
          :class="{ node: true, hasMate: treeData.mate }"
          draggable="true"
          @dragstart="dragStart($event, treeData)"
          @dragover.prevent
          @drop="drop($event, treeData, rootTreeData)"
        >
          <div class="person" :class="Array.isArray(treeData.class) ? treeData.class : []">
            <div :class="{ avat: treeData.id == 1, avat1: treeData.id != 1 }">
              <template v-if="treeData.id == 1">公司</template>
              <template v-else>业务</template>
              <!-- <img :src="treeData.image_url" @contextmenu="$emit('click-node', treeData)" /> -->
            </div>
            <!-- <div class="name">{{treeData.name}}</div> -->
          </div>
          <div :class="{ paeson_name: treeData.id == 1, paeson_name1: treeData.id != 1 }">{{ treeData.label }}</div>
          <!-- <div class="paeson_name">A</div> -->
          <template v-if="Array.isArray(treeData.mate) && treeData.mate.length">
            <div
              class="person"
              v-for="(mate, mateIndex) in treeData.mate"
              :key="treeData.label + mateIndex"
              :class="Array.isArray(mate.class) ? mate.class : []"
              @click="$emit('click-node', mate)"
              draggable="true"
              @dragstart="dragStart($event, mate)"
              @dragover.prevent
              @drop="drop($event, treeData, rootTreeData)"
            >
              <div class="avat">
                <img :src="mate.image_url" />
              </div>
              <!-- <div class="name">{{mate.name}}</div> -->
            </div>
            <div class="paeson_name">{{ treeData.label }}</div>
          </template>
        </div>
        <div
          class="extend_handle"
          v-if="Array.isArray(treeData.children) && treeData.children.length && extend"
          @click="toggleExtend(treeData)"
        ></div>
      </td>
    </tr>
    <tr v-if="Array.isArray(treeData.children) && treeData.children.length && treeData.extend">
      <td v-for="(children, index) in treeData.children" :key="index" colspan="2" class="childLevel">
        <!-- 确保递归调用时传递 rootTreeData -->
        <TreeChart :json="children" :rootTreeData="rootTreeData" @click-node="$emit('click-node', $event)" />
      </td>
    </tr>
  </table>
</template>

<script>
export default {
  name: "TreeChart",
  props: ["json", "rootTreeData", "extend"],
  data() {
    return {
      treeData: {}
    };
  },
  watch: {
    json: {
      handler: function (Props) {
        console.log("接收到的新 json 数据:", Props);
        let extendKey = function (jsonData) {
          jsonData.extend = jsonData.extend === void 0 ? true : !!jsonData.extend;
          if (Array.isArray(jsonData.children)) {
            jsonData.children.forEach((c) => {
              extendKey(c);
            });
          }
          return jsonData;
        };
        if (Props) {
          this.treeData = extendKey(Props);
          console.log("处理后的 treeData:", this.treeData);
        }
      },
      immediate: true
    }
  },
  methods: {
    toggleExtend: function (treeData) {
      treeData.extend = !treeData.extend;
    },
    dragStart(event, node) {
      event.stopPropagation();
      // 将节点信息序列化为字符串存储在 dataTransfer 中
      event.dataTransfer.setData("text/plain", JSON.stringify(node));
      console.log("开始拖拽节点:", node);
    },
    drop(event, targetNode, rootTreeData) {
      event.stopPropagation();
      console.log("尝试将节点放到目标节点:", targetNode);
      // 从 dataTransfer 中获取节点信息并反序列化
      const draggedNodeData = event.dataTransfer.getData("text/plain");
      const draggedNode = JSON.parse(draggedNodeData);
      console.log("当前 draggedNode:", draggedNode);

      // 从根节点开始查找实际的拖拽节点对象
      const realDraggedNode = this.findNodeInTree(rootTreeData, draggedNode);
      console.log("查找结果 realDraggedNode:", realDraggedNode);

      if (realDraggedNode && realDraggedNode !== targetNode) {
        // 检查拖拽节点是否是目标节点的父节点
        if (this.isParent(realDraggedNode, targetNode)) {
          console.log("不能将父节点拖拽到子节点下,操作取消");
          return;
        }
        console.log("开始移除拖拽节点:", realDraggedNode);
        // 移除拖拽节点从原位置
        this.removeNodeFromParent(realDraggedNode, rootTreeData);
        console.log("拖拽节点已从原位置移除");
        // 添加拖拽节点到目标节点的子节点
        if (!targetNode.children) {
          targetNode.children = [];
        }
        targetNode.children.push(realDraggedNode);
        console.log("拖拽节点已添加到目标节点的子节点列表");
        console.log("更新后的 treeData:", rootTreeData);
      } else {
        console.log("拖拽操作无效,draggedNode 为空或 draggedNode 与 targetNode 相同");
      }
    },
    findNodeInTree(parent, targetNode) {
      // 通过比较 name 和 id 来判断是否为同一节点
      const isSameNode = (node1, node2) => {
        return node1.name === node2.name && node1.id === node2.id;
      };

      if (!parent) {
        console.log("当前检查节点为 undefined 或 null,跳过检查");
        return null;
      }

      console.log("正在检查节点:", parent.name);
      if (isSameNode(parent, targetNode)) {
        console.log("找到匹配节点:", parent.name);
        return parent;
      }
      if (Array.isArray(parent.children)) {
        for (const child of parent.children) {
          const found = this.findNodeInTree(child, targetNode);
          if (found) {
            return found;
          }
        }
      }
      console.log("未在该分支找到匹配节点:", parent.name);
      return null;
    },
    removeNodeFromParent(node, rootTreeData) {
      const findAndRemove = (parent) => {
        if (!parent || !Array.isArray(parent.children)) {
          return false;
        }
        // 查找节点在父节点的子节点数组中的索引
        const index = parent.children.findIndex((child) => child === node);
        if (index > -1) {
          console.log("在父节点中找到拖拽节点,索引为:", index);
          // 使用 splice 移除节点
          parent.children.splice(index, 1);
          console.log("已从父节点中移除拖拽节点");
          return true;
        }
        // 递归查找子节点
        for (const child of parent.children) {
          if (findAndRemove(child)) {
            return true;
          }
        }
        return false;
      };
      console.log("开始在 rootTreeData 中查找并移除拖拽节点");
      const result = findAndRemove(rootTreeData);
      if (result) {
        console.log("成功在 rootTreeData 中找到并移除拖拽节点");
      } else {
        console.log("未在 rootTreeData 中找到拖拽节点");
      }
    },
    // 检查 node1 是否是 node2 的父节点
    isParent(node1, node2) {
      const checkParent = (parent) => {
        if (!parent || !Array.isArray(parent.children)) {
          return false;
        }
        if (parent.children.includes(node2)) {
          return true;
        }
        for (const child of parent.children) {
          if (checkParent(child)) {
            return true;
          }
        }
        return false;
      };
      return checkParent(node1);
    }
  }
};
</script>

<style scoped>
table {
  border-collapse: separate !important;
  border-spacing: 0 !important;
}
td {
  position: relative;
  vertical-align: top;
  padding: 0 0 50px 0;
  text-align: center;
}
.extend_handle {
  position: absolute;
  left: 50%;
  bottom: 30px;
  width: 10px;
  height: 10px;
  padding: 10px;
  transform: translate3d(-15px, 0, 0);
  cursor: pointer;
}
.extend_handle:before {
  content: "";
  display: block;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border: 2px solid;
  border-color: #ccc #ccc transparent transparent;
  transform: rotateZ(135deg);
  transform-origin: 50% 50% 0;
  transition: transform ease 300ms;
}
.extend_handle:hover:before {
  border-color: #333 #333 transparent transparent;
}
/* .extend .extend_handle:before{transform: rotateZ(-45deg);} */
.extend::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 15px;
  height: 15px;
  border-left: 2px solid #ccc;
  transform: translate3d(-1px, 0, 0);
}
.childLevel::before {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 100%;
  height: 15px;
  border-left: 2px solid #ccc;
  transform: translate3d(-1px, 0, 0);
}
.childLevel::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -15px;
  border-top: 2px solid #ccc;
}
.childLevel:first-child:before,
.childLevel:last-child:before {
  display: none;
}
.childLevel:first-child:after {
  left: 50%;
  height: 15px;
  border: 2px solid;
  border-color: #ccc transparent transparent #ccc;
  border-radius: 6px 0 0 0;
  transform: translate3d(1px, 0, 0);
}
.childLevel:last-child:after {
  right: 50%;
  height: 15px;
  border: 2px solid;
  border-color: #ccc #ccc transparent transparent;
  border-radius: 0 6px 0 0;
  transform: translate3d(-1px, 0, 0);
}
.childLevel:first-child.childLevel:last-child::after {
  left: auto;
  border-radius: 0;
  border-color: transparent #ccc transparent transparent;
  transform: translate3d(1px, 0, 0);
}
.node {
  position: relative;
  display: inline-block;
  /* margin: 0 1em; */
  box-sizing: border-box;
  text-align: center;
}
.node:hover {
  color: #2d8cf0;
  cursor: pointer;
}
.node .person {
  position: relative;
  display: inline-block;
  z-index: 2;
  width: 100%;
  overflow: hidden;
}
.node .person .avat {
  display: block;
  margin: auto;
  overflow: hidden;
  box-sizing: border-box;
  width: 100%;
  height: 40px;
  line-height: 40px;
  background: #6fb556;
  border-radius: 4px 4px 0px 0px;
  font-family: Source Han Sans CN, Source Han Sans CN;
  font-weight: bold;
  font-size: 16px;
  color: #ffffff;
  text-align: center;
}
.node .person .avat1 {
  display: block;
  margin: auto;
  overflow: hidden;
  box-sizing: border-box;
  width: 100%;
  height: 40px;
  line-height: 40px;
  background: #2469ca;
  border-radius: 4px 4px 0px 0px;
  font-family: Source Han Sans CN, Source Han Sans CN;
  font-weight: bold;
  font-size: 16px;
  color: #ffffff;
  text-align: center;
}
.node .person .avat:hover {
  border: 1px solid #2d8cf0;
}
.node .person .avat img {
  width: 100%;
  height: 100%;
}
.node .person .name {
  height: 2em;
  line-height: 2em;
  overflow: hidden;
  width: 100%;
}
.node.hasMate::after {
  content: "";
  position: absolute;
  left: 2em;
  right: 2em;
  top: 2em;
  border-top: 2px solid #ccc;
  z-index: 1;
}
.node .paeson_name {
  transform: rotate(90deg);
  position: absolute;
  top: 37px;
  right: 27px;
  width: 100%;
  height: 94px;
  text-align: center;
  background: #eff9ee;
  border-radius: 0px 0px 4px 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: Source Han Sans CN, Source Han Sans CN;
  font-weight: bold;
  font-size: 26px;
  color: #333333;
}
.node .paeson_name1 {
  transform: rotate(90deg);
  position: absolute;
  top: 37px;
  right: 27px;
  width: 100%;
  height: 94px;
  text-align: center;
  background: #eceffb;
  border-radius: 0px 0px 4px 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: Source Han Sans CN, Source Han Sans CN;
  font-weight: bold;
  font-size: 26px;
  color: #333333;
}

.landscape {
  transform: translate(-100%, 0) rotate(-90deg);
  transform-origin: 100% 0;
}
.landscape .node {
  text-align: left;
  width: 150px;
  height: calc(40px + 94px);
  right: 18px;
  margin-bottom: 20px;
}
.landscape .person {
  position: absolute;
  transform: rotate(90deg);
  height: 40px;
  top: 4em;
  left: 2.5em;
}
.landscape .person .avat {
  position: absolute;
  left: 0;
  /* border-radius: 2em; */
  /* border-width: 2px; */
}
.landscape .person .name {
  height: 4em;
  line-height: 4em;
}
.landscape .hasMate {
  position: relative;
}
.landscape .hasMate .person {
  position: absolute;
}
.landscape .hasMate .person:first-child {
  left: auto;
  right: -4em;
}
.landscape .hasMate .person:last-child {
  left: -4em;
  margin-left: 0;
}
</style>

<template>
  <div id="app">
    <TreeChart :json="treeData" :class="{ landscape: 1 }" :rootTreeData="treeData" />
  </div>
</template>

<script setup>
import { ref } from "vue";
import TreeChart from "../src/views/test";
const treeData = ref({
  id: 1,
  label: "xxx科技有限公司",
  children: [
    {
      id: 2,
      pid: 1,
      label: "产品研发部",
      children: [
        { id: 5, pid: 2, label: "前端组" },
        { id: 6, pid: 2, label: "后端组" },
        { id: 7, pid: 2, label: "测试组" }
      ]
    },
    {
      id: 3,
      pid: 1,
      label: "客服部",
      children: [
        { id: 11, pid: 3, label: "客服一部" },
        { id: 12, pid: 3, label: "客服二部" }
      ]
    },
    { id: 4, pid: 1, label: "业务部" }
  ]
});



const saveCode = () => {
  // 保存代码逻辑
};

const enableEdit = () => {
  // 编辑代码逻辑
};
</script>

<style>
#app {
  font-family: "Avenir", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
#app .avat {
  border-radius: 2em;
  border-width: 2px;
}

#app .name {
  font-weight: 700;
}
</style>