起因
最近公司想做一个组织架构的拓扑图,要有拖拽,展开收起节点,员工在一条直线上展开提高空间利用率的图形结构
技术了解
刚开始我了解了vue3-tree-org,和OrgChart这两个插件
- vue3-tree-org这个也可以实现一个拓扑图,他利用的还是html立面的标签去生成的树形结构,它也可以自定义,但是拖拽上面不是很灵活,而且加载节点多的话容易卡顿,还有个问题就是它无法把用户全部变成竖列,我尝试过把它变成竖列,但是过于麻烦。如果有简单需求不用太过于定制化可以选择这个插件
- 官网地址:Home | vue3-tree-org
- OrgChart这是一个国外的图形插件,它是利用svg这个来实现图形界面的。它里面可以做很多种图形,我想要的树形结构也在其中,定制化很强,可以满足你大部分需求。但是它的问题是国外插件需要收费,免费版的话很卡顿,因为它调用了国外服务器对节点进行缓存,所以会导致变慢,但是他的定制性真的很强,可以的话可以试试的
最终选定
前面两种因为他们自身的问题我不选择它,但是通过前面了解到,如果想做这种图形类的东西还是以svg或canvas生成来说性能要好些,后来我了解一下d3.js这个专门用来做图表的免费插件,通过了解它确实可以实现我们的需求,具体的细节都不说了,我把我得实现案例放到下面,给大家提供一个思路,对了我这里d3用的是v7版本的
d3js案例
js
```<script setup lang="ts">
import * as d3 from 'd3';
const data = [
{ id: 1, label: '落魄山', name: 'Eve', pid: '', type: 0 },
{ id: 2, label: '祖师堂', name: 'Cain', pid: 1, type: 0 },
{ id: 3, label: '青萍剑宗', name: 'Seth', pid: 1, type: 0 },
{ id: 18, label: '龙象剑宗', name: 'Seth', pid: 1, type: 0 },
{ id: 30, label: '北京', name: 'Seth', pid: 1, type: 0 },
{ id: 31, label: '北京2', name: 'Seth', pid: 1, type: 0 },
{ id: 21, label: '武汉电商', name: 'Seth', pid: 18, type: 0 },
{ id: 22, label: '武汉电商', name: 'Seth', pid: 18, type: 0 },
{ id: 19, label: '广州', name: 'Seth', pid: 1, type: 0 },
{ id: 20, label: '广州', name: 'Seth', pid: 19, type: 0 },
{ id: 4, label: '生产部', name: 'Enos', pid: 3, type: 0 },
{ id: 5, label: '标签部', name: 'Noam', pid: 3, type: 0 },
{ id: 6, label: '综合部', name: 'Abel', pid: 3, type: 0 },
{ id: 7, label: '电商部', name: 'Awan', pid: 3, type: 0 },
{ id: 8, label: '电商一部', name: 'Enoch', pid: 7, type: 0 },
{ id: 9, label: '电商二部', name: 'Azura', pid: 7, type: 0 },
{ id: 10, label: '技术部', name: 'Tech', pid: 2, type: 0 },
{ id: 11, label: '销售部', name: 'Sales', pid: 2, type: 0 },
{ id: 12, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
{ id: 40, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
{ id: 41, label: '人力资源部', name: 'HR', pid: 2, type: 0 },
{ id: 42, label: '人力资源部', name: 'HR', pid: 41, type: 1 },
{
id: 13,
label: '前端开发组',
location: '上海',
name: 'Dev1',
pid: 10,
type: 1,
userName: '陈十一',
},
{
id: 14,
label: '后端开发组',
location: '上海',
name: 'Dev2',
pid: 10,
type: 1,
userName: '陈十一',
},
{
id: 15,
label: '测试组',
location: '上海',
name: 'QA',
pid: 10,
type: 1,
userName: '陈十一',
},
{ id: 16, location: '上海', pid: 11, type: 1, userName: '陈十一' },
{ id: 17, location: '张家港', pid: 11, type: 1, userName: '北海北' },
{ id: 24, location: '上海', pid: 11, type: 1, userName: '陈十一' },
{ id: 25, location: '张家港', pid: 11, type: 1, userName: '北海北' },
{ id: 26, location: '上海', pid: 11, type: 1, userName: '陈十一' },
{ id: 27, location: '张家港', pid: 11, type: 1, userName: '北海北' },
{ id: 26, location: '上海', pid: 11, type: 1, userName: '陈十一' },
{ id: 27, location: '张家港', pid: 11, type: 1, userName: '北海北' },
];
// 初始化变量
let group, root, svg, treeData, treeLayout, zoom;
const margin = { bottom: 50, left: 120, right: 120, top: 50 };
// const width = 1400 - margin.left - margin.right;
// const height = 800 - margin.top - margin.bottom;
let height, width;
const rectX = 200;
const rectY = 80;
const isDragging = shallowRef(false);
const clickFlag = shallowRef(true);
const dragNode = shallowRef(null);
const isVertical = shallowRef(true); // true 水平y轴为纵深 false 垂直x为纵深
const depth = shallowRef(0); // 二级组织数量
const dragStartPoint = ref({ x: 0, y: 0 });
const dragOffset = ref({ x: 0, y: 0 });
// 创建节点
const color = d3.scaleOrdinal(d3.schemeCategory10);
onMounted(() => {
resize();
window.addEventListener('resize', resize);
// treeInit();
});
function resize() {
const treeSvg = document.querySelector('#svg');
width = treeSvg.clientWidth - margin.left - margin.right;
height = treeSvg.clientHeight - margin.top - margin.bottom;
d3.select('#svg>svg').remove();
treeInit();
}
function treeInit() {
// 颜色比例尺
const color = d3.scaleOrdinal(d3.schemeCategory10);
const dataSet = d3
.stratify(data)
.id((d) => {
return d.id;
})
.parentId((d) => {
return d.pid;
})(data);
// 开始创建
// 创建根节点
root = d3.hierarchy(dataSet);
root.x0 = isVertical.value ? width / 2 : height / 2;
root.y0 = 0;
// 展开第一层节点
// root.children.forEach(collapse);
// 创建SVG容器
svg = d3
.select('#svg')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom);
group = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// 添加缩放行为
zoom = d3
.zoom()
.scaleExtent([0.5, 5]) // 缩放范围限制
.on('zoom', zoomHandler);
// 应用缩放到svg容器
svg.call(zoom);
// 创建树布局
treeLayout = d3.tree();
treeLayout(root).each((d) => {
if (d.depth === 0) {
depth.value = d.children.length;
}
});
treeLayout.separation((a, b) => {
const baseSpacing = 0.8;
const depthFactor = Math.max(0, 2 - a.depth * 0.2);
return a.parent === b.parent ? baseSpacing * depthFactor : 1.5;
});
// 更新树图
update(root, true);
}
// 新增缩放处理函数
function zoomHandler(event) {
group.attr('transform', event.transform);
}
// 切换树形结构得方向
function changeTreeDirection() {
isVertical.value = !isVertical.value;
d3.select('#svg>svg').remove();
treeLayout(root);
treeInit();
}
// 适配树形结构
function adaptTree() {
const currentTransform = d3.zoomIdentity
.translate(
isVertical.value
? width * 0.12 * depth.value
: width * 0.02 * depth.value,
isVertical.value
? height * 0.02 * depth.value
: height * (depth.value * 0.12)
) // 减少初始偏移量
.scale(
isVertical.value ? 0.14 * (8 - depth.value) : 0.12 * (8 - depth.value)
); // 这里得缩放比例也得计算
// 添加平滑过渡
svg.call(zoom.transform, currentTransform);
}
// 更新树图
function update(source, flag = false) {
treeLayout.nodeSize([isVertical.value ? 180 : 90, 80]);
// 在节点更新前保存当前缩放状态
let currentTransform;
if (flag) {
currentTransform = d3.zoomIdentity
.translate(
isVertical.value
? width * 0.12 * depth.value
: width * 0.01 * depth.value,
isVertical.value
? height * 0.01 * depth.value
: height * (depth.value * 0.12)
) // 减少初始偏移量
.scale(
isVertical.value ? 0.14 * (8 - depth.value) : 0.12 * (8 - depth.value)
); // 这里得缩放比例也得计算
}
// 应用树布局
const tree = treeLayout(root);
// 获取所有节点
const nodes = tree.descendants();
// 获取所有连接
const links = tree.links();
// 处理节点位置
nodes.forEach((d) => {
if (d.data.data.type === 0) {
if (isVertical.value) {
d.y = d.depth * 310;
// 添加横向间距计算
if (d.parent) {
const siblings = d.parent.children;
const index = siblings.indexOf(d);
d.x += (index - siblings.length / 2) * 12; // 添加30px的横向偏移
}
} else {
d.y = d.depth * 540;
// 添加横向间距计算
if (d.parent) {
const findItem = data.find((item) => item.id === d.data.data.id);
const siblings = d.parent.children;
const index = siblings.indexOf(d);
d.x +=
(index - siblings.length / 2) * (findItem.type === 1 ? 60 : 10); // 添加30px的横向偏移
}
}
}
});
// 把员工变成水平显示
changeUserDirection(nodes, source);
// 添加连接
const link = group.selectAll('.link').data(links, (d) => d.target.data.id);
// 退出过渡
link.exit().transition().duration(100).attr('d', directionLine()).remove();
// 进入过渡
const linkEnter = link
.enter()
.insert('path', '.node') // 在节点前插入连接线
.attr('class', 'link')
.attr('d', directionLine())
.attr('fill', 'none')
.attr('stroke', '#95a5a6')
.attr('stroke-width', 2);
// 更新过渡
link
.merge(linkEnter)
.transition()
.duration(300)
.ease(d3.easeQuadInOut)
.attr('d', directionLine());
// 添加节点组
const node = group.selectAll('.node').data(nodes, (d) => d.data.id);
// 退出过渡
const nodeExit = node
.exit()
.transition()
.duration(300)
.attr('transform', (d) => directionNode(source))
.remove();
// 节点进入
const nodeEnter = node
.enter()
.append('g')
.attr('class', 'node')
.attr('transform', (d) => {
return directionNode(source);
})
.call((g) => {
g.filter((d) => d.depth > 0)
.filter(function () {
return !d3.select(this).select('.expand-icon').node();
})
.call(
d3.drag().on('start', startDrag).on('drag', drag).on('end', endDrag)
);
});
// 添加矩形
const depRect = nodeEnter.filter((node) => {
const findItem = data.find((item) => item.id === node.data.data.id);
return findItem && findItem.type === 0;
});
addRect(depRect, rectX, rectY);
const userRect = nodeEnter.filter((node) => {
const findItem = data.find((item) => item.id === node.data.data.id);
return findItem && findItem.type === 1;
});
addRect(userRect, 100, 140);
rectStyle(depRect, -58, -24);
rectStyle(userRect, -45, -24);
// 节点更新
const nodeUpdate = node
.merge(nodeEnter)
.transition()
.duration(300)
.ease(d3.easeQuadInOut) // 添加缓动函数
.attr('transform', (d) => {
return directionNode(d);
});
// 展开和收起icon
expandIcon(nodeEnter, nodeUpdate);
// 在节点更新后恢复缩放状态
if (flag) {
svg.call(zoom.transform, currentTransform);
}
}
// 切换员工的展示方向
function changeUserDirection(nodes) {
nodes.forEach((node, idx) => {
if (node?.children) {
const userType = node.children.every((item) => {
return item.data.data.type === 1;
});
if (userType) {
const parentX = isVertical.value ? node.y : node.x;
const parentY = isVertical.value ? node.x : node.y;
node.children.forEach((ite, index) => {
ite.x = isVertical.value ? parentY + 50 : parentX - 0;
ite.y = isVertical.value
? parentX + 180 * (index + 1) + 150
: parentY + 180 * (index + 1) + 150;
});
}
}
});
}
// 添加矩形
function addRect(nodeEnter: any, width: number, height: number) {
nodeEnter
.append('rect')
.attr('width', width)
.attr('height', height)
.attr('x', isVertical.value ? -60 : -60)
.attr('y', -30)
.attr('rx', 8)
.attr('ry', 8)
.attr('fill', (d) => (d.children || d._children ? '#3498db' : '#e74c3c'));
}
// 展开和收起得icon
function expandIcon(nodeEnter, nodeUpdate) {
// 添加展开/折叠图标
nodeEnter
.filter((d) => {
return d.children || d._children;
})
.append('g')
.attr('class', 'expand-icon')
.attr(
'transform',
isVertical.value ? 'translate(50,54)' : 'translate(150,10)'
) // 调整图标位置
.call((g) => {
g.append('circle')
.attr('r', 12)
.attr('fill', '#fff')
.attr('stroke', '#666')
.attr('cursor', 'pointer');
g.append('path')
.attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6')) // 展开状态显示横线,收起显示十字
.attr('stroke', '#666')
.attr('stroke-width', 2)
.attr('transform', 'translate(0,2)');
})
.on('click', click);
// 动态更新展开图标
nodeUpdate.each(function (d) {
const g = d3.select(this);
const hasChildren = d.children || d._children;
// 添加或移除图标
if (hasChildren) {
if (g.select('.expand-icon').empty()) {
const icon = g
.append('g')
.attr('class', 'expand-icon')
.attr(
'transform',
isVertical.value ? 'translate(50,54)' : 'translate(150,10)'
) // 调整图标位置)
.on('click', click);
icon
.append('circle')
.attr('r', 12)
.attr('fill', '#fff')
.attr('stroke', '#666')
.attr('cursor', 'pointer');
icon
.append('path')
.attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6')) // 展开状态显示横线,收起显示十字
.attr('stroke', '#666')
.attr('stroke-width', 2)
.attr('transform', 'translate(0,2)');
}
} else {
g.select('.expand-icon').remove();
}
});
// 更新图标
nodeUpdate
.select('.expand-icon path')
.attr('d', (d) => (d.children ? 'M-6,0 H6' : 'M0,-6 V6 M-6,0 H6'))
.transition()
.duration(200)
.attr('transform', (d) => `translate(0,0)`);
// 移除没有子元素的图标
nodeUpdate
.select('.expand-icon')
.filter((d) => !d.children && !d._children)
.remove();
}
// 开始拖拽
function startDrag(e, d) {
// 添加事件传播阻止
e.sourceEvent.stopPropagation();
e.sourceEvent.stopImmediatePropagation();
// 重置拖拽状态
isDragging.value = false;
dragNode.value = d;
// 获取相对于group容器的坐标
const [startX, startY] = d3.pointer(e.sourceEvent, group.node());
dragStartPoint.value = { x: startX, y: startY };
dragOffset.value = { x: 0, y: 0 };
// 标记当前节点为拖拽状态
d3.select(this).classed('dragging', true);
// 标记连接线为拖拽状态
group
.selectAll('.link')
.filter((link) => link.source === d || link.target === d)
.classed('dragging-link', true);
}
// 拖拽中
function drag(e, d) {
// if (!isDragging.value) return;
const [currentX, currentY] = d3.pointer(e.sourceEvent, group.node());
const dx = currentX - dragStartPoint.value.x;
const dy = currentY - dragStartPoint.value.y;
const distance = Math.hypot(dx, dy);
if (distance > 5) {
isDragging.value = true;
dragOffset.value = { x: dx, y: dy };
const safeCoord = (coord: number) => coord || 0;
// 计算偏移量
dragOffset.value = {
x: safeCoord(currentX) - safeCoord(dragStartPoint.value.x) - 52,
y: safeCoord(currentY) - safeCoord(dragStartPoint.value.y) - 18,
}; // 移动的距离要进行计算
// 移动当前节点
d3.select(this).attr(
'transform',
`translate(${
safeCoord(dragStartPoint.value.x) + safeCoord(dragOffset.value.x)
},
${safeCoord(dragStartPoint.value.y) + safeCoord(dragOffset.value.y)})`
);
// 移动所有连接线
updateLinks(d);
}
}
// 拖拽结束
function endDrag(e, d) {
if (!isDragging.value) {
// 如果没有实际拖拽,触发点击事件
return;
}
isDragging.value = false;
// 移除拖拽状态
d3.select(this).classed('dragging', false);
// 移除连接线拖拽状态
group.selectAll('.dragging-link').classed('dragging-link', false);
// 查找最近的节点作为父节点
const targetNode = findNearestNode(e.sourceEvent);
if (targetNode && isValidDrop(d, targetNode)) {
// 从原父节点移除
if (d.parent) {
const index = d.parent.children.indexOf(d);
if (index > -1) {
d.parent.children.splice(index, 1);
if (d.parent.children.length === 0) {
delete d.parent.children;
delete d.parent.data.children;
}
}
}
// 添加到新父节点
if (!targetNode.children) targetNode.children = [];
d.data.data.pid = targetNode.data.data.id;
if (targetNode._children) {
targetNode._children.push(d);
targetNode.children = null;
} else {
targetNode.children.push(d); // 目前是拖拽后y的值没有变化 这个问题要解决
}
d.parent = targetNode;
}
// 重新计算布局
treeLayout(root);
update(d.parent);
}
// 部门 员工样式
function rectStyle(node, x: number, y: number) {
const img =
'https://p9-flow-imagex-sign.byteimg.com/ocean-cloud-tos/image_skill/e589127d-d495-4599-970c-d89b7ab3bb41_1751595979774603863~tplv-a9rns2rl98-web-thumb-watermark-v2.jpeg?rk3s=b14c611d&x-expires=1783131980&x-signature=1fNRgbX8doj9Sm9yY8kdPr6Gd30%3D';
node
.append('g') // 使用分组包裹图片和边框
.attr('transform', `translate(${x},${y})`) // 调整位置使其居中
.append('image')
.attr('xlink:href', (d) => img) // 从数据中获取图片路径
.attr('width', 70)
.attr('height', 70)
.attr('x', 0)
.attr('y', 0)
.attr('clip-path', 'circle(35px at 35px 35px)') // 圆形裁剪
.attr('stroke', '#fff') // 边框颜色
.attr('stroke-width', 2); // 边框宽度
// 添加公司名称
node
.append('text')
.attr('dx', '3em')
.attr('dy', '.3em')
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('font-family', 'Microsoft YaHei')
.attr('fill', 'white')
.text((d) => d.data.data.label);
// 添加公司人数
node
.append('text')
.attr('dx', '7em')
.attr('dy', '.3em')
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('font-family', 'Microsoft YaHei')
.attr('fill', 'white')
.text((d) => '1/100');
// 添加公司或者部门负责人
node
.append('text')
.attr('dx', '3em')
.attr('dy', '2.3em')
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('font-family', 'Microsoft YaHei')
.attr('fill', 'white')
.text((d) => d.data.data.name);
// 用户名称
node
.append('text')
.attr('x', -10)
.attr('dy', '4.6em')
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('font-family', 'Microsoft YaHei')
.attr('fill', 'white')
.text((d) => d.data.data.userName);
// 用户工作地点
node
.append('text')
.attr('x', -10)
.attr('dy', '6em')
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('font-family', 'Microsoft YaHei')
.attr('fill', 'white')
.text((d) => d.data.data.location);
}
// 水平或垂直的连线
function directionLine() {
return isVertical.value
? d3
.linkVertical()
.x((d) => {
const item = data.find((ite) => ite.id === d.data.data.id);
return item.type === 0 ? d.x + 50 : d.x;
})
.y((d) => {
return d.y;
})
: d3
.linkHorizontal()
.x((d) => {
return d.y + 90;
})
.y((d) => {
return d.x + 10;
});
}
// 修改水平或者垂直时节点的位置
function directionNode(d) {
const isChildrenX = d.children ? d.y - 20 : d.y + 60;
let x: number, y: number;
if (d.data.data.type === 0) {
y = isVertical.value ? d.y - 40 : d.y;
x = isVertical.value ? d.y : isChildrenX;
return isVertical.value
? `translate(${d.x},${y})`
: `translate(${y},${d.x})`;
} else {
x = isVertical.value ? d.y : isChildrenX;
y = isVertical.value ? d.y - 40 : d.y;
return isVertical.value
? `translate(${d.x},${y})`
: `translate(${x},${d.x - 20})`;
}
}
// 更新连接线位置 - 修复版本
function updateLinks(d) {
const getValidCoord = (value) => (isNaN(value) ? 0 : value);
// 更新从父节点到当前节点的连接线
group
.selectAll('.link')
.filter((link) => link.target === d)
.attr('d', directionLine());
// 更新从当前节点到子节点的连接线
if (d.children) {
d.children.forEach((child) => {
group
.selectAll('.link')
.filter((link) => link.source === d && link.target === child)
.attr('d', directionLine());
});
}
}
// 新增辅助函数:查找最近的节点
function findNearestNode(event) {
const [x, y] = d3.pointer(event, group.node());
let minDist = Infinity;
let nearestNode = null;
group.selectAll('.node').each(function (d) {
const bbox = this.getBBox();
// 根据布局方向调整坐标计算
const centerX = isVertical.value
? d.x + bbox.x + bbox.width / 2 // 垂直布局时x轴是层级坐标
: d.y + bbox.x + bbox.width / 2; // 水平布局时y轴是层级坐标
const centerY = isVertical.value
? d.y + bbox.y + bbox.height / 2 // 垂直布局时y轴是兄弟节点坐标
: d.x + bbox.y + bbox.height / 2; // 水平布局时x轴是兄弟节点坐标
const dist = Math.hypot(centerX - x, centerY - y);
if (dist < 160 && dist < minDist) {
// 80px为有效吸附距离
minDist = dist;
nearestNode = d;
}
});
return nearestNode;
}
// 新增辅助函数:验证拖拽有效性
function isValidDrop(draggedNode, targetNode) {
// 不能挂载到自己或子节点
if (draggedNode === targetNode || draggedNode.depth <= targetNode.depth)
return false;
let parent = targetNode;
while (parent) {
if (parent === draggedNode) return false;
parent = parent.parent;
}
return true;
}
// 节点点击事件
function click(event, d) {
clickFlag.value = false;
if (d.children) {
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d._children = null;
// 展开后重新计算布局
treeLayout(root);
}
// 局部更新树图
update(d);
}
// 全部展开
function expandAll() {
root.descendants().forEach((d) => {
if (d._children) {
d.children = d._children;
d._children = null;
}
});
update(root);
}
// 全部收起
function collapseAll() {
root.descendants().forEach((d) => {
if (d.children && d.depth > 0) {
d._children = d.children;
d.children = null;
}
});
update(root);
}
// 重置视图
function resetView() {
root.descendants().forEach((d) => {
if (d.depth === 0 && d.children) {
// 根节点保持展开
} else if (d.depth === 1) {
// 第一层节点展开
if (d._children) {
d.children = d._children;
d._children = null;
}
} else if (
d.depth > 1 && // 更深层节点收起
d.children
) {
d._children = d.children;
d.children = null;
}
});
update(root);
}
// 折叠节点
function collapse(d) {
if (d.children) {
d._children = d.children;
d._children.forEach(collapse);
d.children = null;
}
}
</script>
<template>
<div class="my-2 flex gap-x-3">
<div class="w-25 border-blue h-10 cursor-pointer rounded-full border text-center leading-10"
@click="changeTreeDirection">
切换
</div>
<div class="w-25 border-blue h-10 cursor-pointer rounded-full border text-center leading-10"
@click="adaptTree">
适配
</div>
</div>
<div id="svg"></div>
</template>
<style>
#svg {
height: 65vh;
margin: 0 auto;
overflow: hidden;
border: 1px solid #ccc;
}
.expand-icon {
pointer-events: none;
cursor: pointer;
transition: transform 0.3s;
/* 防止图标遮挡点击 */
}
.expand-icon circle {
/* 恢复圆圈点击 */
z-index: 2;
pointer-events: all;
/* 确保图标在连线上方 */
}
.expand-icon {
z-index: 3;
cursor: pointer;
transition: transform 0.3s, opacity 0.3s;
transform-origin: center;
}
.node {
/* transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); */
z-index: 2;
/* 添加节点整体过渡 */
}
.link {
/* transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); */
z-index: 1;
/* 连线过渡 */
}
</style>