组件概述
TreeChart 是一款基于vue3二次开源的的树形结构可视化组件,专为展示层级关系数据而设计。该组件通过直观的图形界面和丰富的交互功能,为用户提供了高效的数据组织和展示方案。
源代码地址:blog.csdn.net/qq_45424679…
核心功能亮点
-
层级结构可视化
- 支持多级嵌套节点展示
- 提供展开/折叠功能,便于查看复杂结构
- 自动计算节点布局,确保视觉效果清晰
- 组件采用递归方式渲染多层级节点结构
-
智能拖拽交互
- 实现完整的拖拽交互流程(dragStart/drop)
- 内置防错机制,防止父子节点循环引用
- 通过递归算法查找和更新节点位置
-
多样化样式定制
- 提供两种基础节点样式(目标/漫延)
- 支持水平和垂直两种布局方式
- 丰富的CSS类名,便于扩展样式
-
数据响应式更新
- 自动监听数据变化
- 递归处理节点属性
- 提供默认值处理机制
应用价值
-
提升用户体验
- 直观展示复杂层级关系
- 简化数据操作流程
- 增强用户对数据结构的理解
-
提高开发效率
- 开箱即用的解决方案
- 简洁的API设计
- 灵活的扩展接口
-
业务场景适配
- 组织结构管理
- 知识图谱构建
- 流程关系展示
- 系统架构可视化
技术实现亮点
-
数据结构处理
- 使用深度优先遍历处理节点树
- 通过watch监听实现数据响应式
- 采用JSON序列化传递节点信息
-
交互设计
- 拖拽时传递完整节点数据
- 精确查找源节点在树中的实际引用
- 提供详细的调试日志输出
应用场景建议
-
组织架构图
- 可视化展示公司部门层级关系
- 支持动态调整组织架构
-
业务流程图
- 展示业务流程间的关联关系
- 支持自由重组业务节点
-
项目管理
- 构建项目任务分解结构
- 通过拖拽调整任务关系
使用注意事项
- 数据格式需包含label/id属性
- 根节点需保持引用一致
- 节点操作会直接修改原始数据
- 复杂数据结构可能影响性能
<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>