数据同步逻辑修改

This commit is contained in:
冯少康 2026-08-07 16:39:41 +08:00
parent 5c50d114b4
commit cedc6ca7a1
3 changed files with 835 additions and 437 deletions

View File

@ -1,9 +1,9 @@
import type { Edge, Node, NodeConfig } from '@antv/x6';
import { Graph } from '@antv/x6';
import { v4 as uuidv4 } from 'uuid';
import type { Edge, Node, NodeConfig } from "@antv/x6";
import { Graph } from "@antv/x6";
import { v4 as uuidv4 } from "uuid";
// 层级类型定义
export type NodeLevel = 'level2' | 'level3' | 'level4';
export type NodeLevel = "level2" | "level3" | "level4";
// 目标灌库数据的类型定义
interface ImportDataItem {
@ -50,14 +50,14 @@ export class X6Graph {
private readonly nodeStyle = {
width: 100,
height: 40,
fill: '#0a4a5c', // 深色背景(示例中的深青色)
stroke: '#0a4a5c', // 边框与背景同色,视觉上隐藏边框
fill: "#0a4a5c", // 深色背景(示例中的深青色)
stroke: "#0a4a5c", // 边框与背景同色,视觉上隐藏边框
strokeWidth: 2,
radius: 4,
label: {
fill: '#93CDD5', // 白色文字
fill: "#93CDD5", // 白色文字
fontSize: 14,
fontWeight: 'bold', // 可选:文字加粗
fontWeight: "bold", // 可选:文字加粗
},
};
@ -69,16 +69,16 @@ export class X6Graph {
// 层级X坐标映射
private readonly levelXMap = new Map<NodeLevel, number>([
['level2', 300],
['level3', 550],
['level4', 1000],
["level2", 300],
["level3", 550],
["level4", 1000],
]);
// 层级节点数量记录
private readonly levelCountMap = new Map<NodeLevel, number>([
['level2', 0],
['level3', 0],
['level4', 0],
["level2", 0],
["level3", 0],
["level4", 0],
]);
constructor(containerId: string) {
@ -87,7 +87,12 @@ export class X6Graph {
}
private createMenuElement() {
this.menuElement = document.createElement('div');
this.menuElement = document.getElementById("ant-x6-menu4");
if(this.menuElement){
this.menuElement.remove();
}
this.menuElement = document.createElement("div");
this.menuElement.id = "ant-x6-menu4";
this.menuElement.style.cssText = `
position: absolute;
width: 120px;
@ -101,18 +106,22 @@ export class X6Graph {
font-family: sans-serif;
`;
// 1. 配置同步字段:新增“配置”菜单项
const configItem = document.createElement('div');
configItem.id = 'ant-x6-menu-1';
configItem.style.cssText = `
// 3. 配置
let pzItem = document.getElementById("ant-x6-menu");
if (pzItem) {
pzItem.remove();
}
pzItem = document.createElement("div");
pzItem.id = "ant-x6-menu";
pzItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
`;
configItem.textContent = '配置同步字段';
configItem.addEventListener('click', () => {
const nodeId = this.menuElement?.getAttribute('data-node-id');
pzItem.textContent = "配置";
pzItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
@ -122,35 +131,41 @@ export class X6Graph {
// 2. 高亮样式(保持原有)
targetNode.attr({
body: {
stroke: 'rgb(12,129,123)',
stroke: "rgb(12,129,123)",
strokeWidth: 2,
strokeDasharray: '5, 5',
strokeDasharray: "5, 5",
},
});
// ======================== 新增:获取所有父级信息 ========================
// 1. 定义追溯父级的方法
const getAllParents = (targetNodeId: string): Array<{ id: string; label: string; level?: NodeLevel }> => {
const parents: Array<{ id: string; label: string; level?: NodeLevel }> = [];
const getAllParents = (
targetNodeId: string,
): Array<{ id: string; label: string; level?: NodeLevel }> => {
const parents: Array<{
id: string;
label: string;
level?: NodeLevel;
}> = [];
let currentNodeId = targetNodeId;
// 循环追溯:直到找不到父节点(根节点)
while (true) {
// 找到指向当前节点的连线(来源即父节点)
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === currentNodeId);
if (!parentEdge)
break; // 无父节点,终止循环
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === currentNodeId);
if (!parentEdge) break; // 无父节点,终止循环
// 获取父节点ID和父节点实例
const parentNodeId = parentEdge.getSourceCellId();
const parentNode = graph.getCellById(parentNodeId) as Node;
if (!parentNode)
break;
if (!parentNode) break;
// 收集父节点信息ID、标签、层级
parents.push({
id: parentNodeId,
label: parentNode.attr('label/text') || '', // 节点显示的文字
label: parentNode.attr("label/text") || "", // 节点显示的文字
level: parentNode.getData().level, // 父节点的层级
});
@ -169,45 +184,155 @@ export class X6Graph {
// 关键添加当前节点信息含label拼接成“父级+当前节点”的完整链
const currentNodeInfo = {
id: nodeId,
label: targetNode.attr('label/text') || '', // 从当前节点获取label
label: targetNode.attr("label/text") || "", // 从当前节点获取label
level: nodeLevel,
};
allParentsOrdered = [...allParentsOrdered, currentNodeInfo];
console.log('目标节点(父级+自身)完整链:', allParentsOrdered);
console.log("目标节点(父级+自身)完整链:", allParentsOrdered);
// ========================================================================
// 3. 事件总线传递:携带完整链信息
EventBusTool.getEventBus().publish('antvX6Event', {
key: 'configNode',
EventBusTool.getEventBus().publish("antvX6Event", {
key: "configNode",
id: nodeId,
label: targetNode.attr('label/text') || '',
label: targetNode.attr("label/text") || "",
level: nodeLevel,
allParents: allParentsOrdered, // 包含父级+当前节点均有label
});
}
this.hideMenu();
});
// 配置悬停效果
configItem.addEventListener('mouseenter', () => {
configItem.style.background = '#f0f0f0';
// 配置菜单悬停效果
pzItem.addEventListener("mouseenter", () => {
pzItem.style.background = "#f0f0f0";
});
configItem.addEventListener('mouseleave', () => {
configItem.style.background = 'white';
pzItem.addEventListener("mouseleave", () => {
pzItem.style.background = "white";
});
// 1. 配置同步字段:新增“配置”菜单项
let configItem = document.getElementById("ant-x6-menu-1");
if (configItem) {
configItem.remove();
}
configItem = document.createElement("div");
configItem.id = "ant-x6-menu-1";
configItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
`;
configItem.textContent = "配置同步字段";
configItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
const nodeData = targetNode.getData();
const nodeLevel = nodeData.level;
// 2. 高亮样式(保持原有)
targetNode.attr({
body: {
stroke: "rgb(12,129,123)",
strokeWidth: 2,
strokeDasharray: "5, 5",
},
});
// ======================== 新增:获取所有父级信息 ========================
// 1. 定义追溯父级的方法
const getAllParents = (
targetNodeId: string,
): Array<{ id: string; label: string; level?: NodeLevel }> => {
const parents: Array<{
id: string;
label: string;
level?: NodeLevel;
}> = [];
let currentNodeId = targetNodeId;
// 循环追溯:直到找不到父节点(根节点)
while (true) {
// 找到指向当前节点的连线(来源即父节点)
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === currentNodeId);
if (!parentEdge) break; // 无父节点,终止循环
// 获取父节点ID和父节点实例
const parentNodeId = parentEdge.getSourceCellId();
const parentNode = graph.getCellById(parentNodeId) as Node;
if (!parentNode) break;
// 收集父节点信息ID、标签、层级
parents.push({
id: parentNodeId,
label: parentNode.attr("label/text") || "", // 节点显示的文字
level: parentNode.getData().level, // 父节点的层级
});
// 下一轮追溯:以父节点为当前节点,找它的父节点
currentNodeId = parentNodeId;
}
return parents; // 返回所有父级(顺序:直接父级 → 祖父级 → ... → 根节点父级)
};
// 2. 执行追溯,获取所有父级
const allParentNodes = getAllParents(nodeId);
// 反转数组,让顺序变为“根节点父级 → 祖父级 → 直接父级”
let allParentsOrdered = allParentNodes.reverse();
// 关键添加当前节点信息含label拼接成“父级+当前节点”的完整链
const currentNodeInfo = {
id: nodeId,
label: targetNode.attr("label/text") || "", // 从当前节点获取label
level: nodeLevel,
};
allParentsOrdered = [...allParentsOrdered, currentNodeInfo];
console.log("目标节点(父级+自身)完整链:", allParentsOrdered);
// ========================================================================
// 3. 事件总线传递:携带完整链信息
EventBusTool.getEventBus().publish("antvX6Event", {
key: "configNode",
id: nodeId,
label: targetNode.attr("label/text") || "",
level: nodeLevel,
allParents: allParentsOrdered, // 包含父级+当前节点均有label
isTbsl:false,
});
}
this.hideMenu();
});
// 配置项悬停效果
configItem.addEventListener("mouseenter", () => {
configItem.style.background = "#f0f0f0";
});
configItem.addEventListener("mouseleave", () => {
configItem.style.background = "white";
});
// 2. 配置同步数量
const syncNumItem = document.createElement('div');
syncNumItem.id = 'ant-x6-menu-2';
let syncNumItem = document.getElementById("ant-x6-menu-2");
if (syncNumItem) {
syncNumItem.remove();
}
syncNumItem = document.createElement("div");
syncNumItem.id = "ant-x6-menu-2";
syncNumItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
`;
syncNumItem.textContent = '配置同步数量';
syncNumItem.addEventListener('click', () => {
const nodeId = this.menuElement?.getAttribute('data-node-id');
syncNumItem.textContent = "配置同步数量";
syncNumItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
@ -215,28 +340,40 @@ export class X6Graph {
// TODO配置同步数量逻辑
// 3. 事件总线传递:携带完整链信息
EventBusTool.getEventBus().publish("antvX6Event", {
key: "configNode",
id: nodeId,
label: targetNode.attr("label/text") || "",
level: nodeLevel,
isTbsl:true,
});
}
this.hideMenu();
});
// 配置同步数量菜单悬停效果
syncNumItem.addEventListener('mouseenter', () => {
syncNumItem.style.background = '#f0f0f0';
syncNumItem.addEventListener("mouseenter", () => {
syncNumItem.style.background = "#f0f0f0";
});
syncNumItem.addEventListener('mouseleave', () => {
syncNumItem.style.background = 'white';
syncNumItem.addEventListener("mouseleave", () => {
syncNumItem.style.background = "white";
});
// 3. 原有“删除”菜单项(保持不变,调整顺序在配置项下方)
const deleteItem = document.createElement('div');
deleteItem.id = 'ant-x6-menu-3';
let deleteItem = document.getElementById("ant-x6-menu-3");
if (deleteItem) {
deleteItem.remove();
}
deleteItem = document.createElement("div");
deleteItem.id = "ant-x6-menu-3";
deleteItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
`;
deleteItem.textContent = '删除';
deleteItem.addEventListener('click', () => {
const nodeId = this.menuElement?.getAttribute('data-node-id');
deleteItem.textContent = "删除";
deleteItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
// 获取图形实例和目标节点(和配置项逻辑一致)
const graph = this.getGraph();
@ -244,8 +381,8 @@ export class X6Graph {
// 从节点数据中提取level
const nodeLevel = targetNode.getData().level;
this.removeNode(nodeId);
EventBusTool.getEventBus().publish('antvX6Event', {
key: 'removeNode',
EventBusTool.getEventBus().publish("antvX6Event", {
key: "removeNode",
id: nodeId,
level: nodeLevel, // 新增传递level信息
});
@ -253,14 +390,15 @@ export class X6Graph {
this.hideMenu();
});
// 删除项悬停效果
deleteItem.addEventListener('mouseenter', () => {
deleteItem.style.background = '#f0f0f0';
deleteItem.addEventListener("mouseenter", () => {
deleteItem.style.background = "#f0f0f0";
});
deleteItem.addEventListener('mouseleave', () => {
deleteItem.style.background = 'white';
deleteItem.addEventListener("mouseleave", () => {
deleteItem.style.background = "white";
});
// 3. 将两个菜单项添加到菜单容器(配置项在上,删除项在下)
this.menuElement.appendChild(pzItem);
this.menuElement.appendChild(configItem);
this.menuElement.appendChild(syncNumItem);
this.menuElement.appendChild(deleteItem);
@ -271,12 +409,11 @@ export class X6Graph {
*
*/
private showMenu(nodeId: string, x: number, y: number) {
if (!this.menuElement)
return;
this.menuElement.setAttribute('data-node-id', nodeId);
if (!this.menuElement) return;
this.menuElement.setAttribute("data-node-id", nodeId);
this.menuElement.style.left = `${x}px`;
this.menuElement.style.top = `${y}px`;
this.menuElement.style.display = 'block';
this.menuElement.style.display = "block";
}
/**
@ -284,7 +421,7 @@ export class X6Graph {
*/
private hideMenu() {
if (this.menuElement) {
this.menuElement.style.display = 'none';
this.menuElement.style.display = "none";
}
}
@ -302,14 +439,14 @@ export class X6Graph {
container,
width: container.clientWidth,
height: container.clientHeight,
grid: options.grid ?? { size: 10, visible: false, type: 'dot' },
grid: options.grid ?? { size: 10, visible: false, type: "dot" },
mousewheel: options.zoom ?? {
enabled: true,
modifiers: 'ctrl',
modifiers: "ctrl",
minScale: 0.5,
maxScale: 2,
},
panning: { enabled: true, modifier: 'shift' },
panning: { enabled: true, modifier: "shift" },
snapline: true,
// 关键:完善选择配置,确保单击和框选生效
selecting: {
@ -318,20 +455,24 @@ export class X6Graph {
});
// 注册“直接曲线虚线”自定义边
Graph.registerEdge('direct-curved-dashed-edge', {
inherit: 'edge',
// 连接器:使用 smooth平滑曲线无额外拐点
connector: { name: 'smooth' },
attrs: {
line: {
targetMarker: '', // 移除目标标记(可选)
stroke: '#A2B1C3', // 线条颜色
strokeWidth: 2, // 线条宽度
strokeDasharray: '5, 5', // 虚线线段5px + 间隔5px
Graph.registerEdge(
"direct-curved-dashed-edge",
{
inherit: "edge",
// 连接器:使用 smooth平滑曲线无额外拐点
connector: { name: "smooth" },
attrs: {
line: {
targetMarker: "", // 移除目标标记(可选)
stroke: "#A2B1C3", // 线条颜色
strokeWidth: 2, // 线条宽度
strokeDasharray: "5, 5", // 虚线线段5px + 间隔5px
},
},
zIndex: 0, // 层级(避免遮挡节点)
},
zIndex: 0, // 层级(避免遮挡节点)
}, true);
true,
);
// 绑定右键菜单事件
this.bindRightClickEvent();
@ -346,7 +487,7 @@ export class X6Graph {
const graph = this.getGraph();
// 节点右键点击
graph.on('node:contextmenu', (args) => {
graph.on("node:contextmenu", (args) => {
args.e.preventDefault(); // 阻止浏览器默认右键菜单
const { cell, e } = args;
const node = cell as Node;
@ -360,25 +501,40 @@ export class X6Graph {
return;
}
if (nodeData.level === 'level2') {
} else if (nodeData.level === 'level3') {
} else if (nodeData.level === 'level4') {
if (nodeData.level === "level2") {
const item2 = document.getElementById("ant-x6-menu");
item2.style.display = "block";
const item = document.getElementById("ant-x6-menu-1");
item.style.display = "none";
const item1 = document.getElementById("ant-x6-menu-2");
item1.style.display = "none";
} else if (nodeData.level === "level3") {
const item = document.getElementById("ant-x6-menu-1");
item.style.display = "block";
const item1 = document.getElementById("ant-x6-menu-2");
item1.style.display = "block";
const item2 = document.getElementById("ant-x6-menu");
item2.style.display = "none";
} else if (nodeData.level === "level4") {
const item = document.getElementById("ant-x6-menu-1");
item.style.display = "none";
const item1 = document.getElementById("ant-x6-menu-2");
item1.style.display = "none";
const item2 = document.getElementById("ant-x6-menu");
item2.style.display = "none";
}
// 显示菜单(位置为点击坐标)
this.showMenu(node.id, e.clientX, e.clientY);
});
// 画布空白处右键点击(隐藏菜单)
graph.on('blank:contextmenu', (args) => {
graph.on("blank:contextmenu", (args) => {
args.e.preventDefault();
this.hideMenu();
});
// 点击其他区域隐藏菜单
document.addEventListener('click', () => {
document.addEventListener("click", () => {
this.hideMenu();
});
}
@ -388,7 +544,7 @@ export class X6Graph {
*/
private calculateTextWidth(text: string, fontSize = 14): number {
// 创建临时元素计算文字宽度
const tempSpan = document.createElement('span');
const tempSpan = document.createElement("span");
tempSpan.style.cssText = `
position: absolute;
visibility: hidden;
@ -430,9 +586,11 @@ export class X6Graph {
private addNode(config: SimpleNodeConfig): Node {
const graph = this.getGraph();
// 计算根节点自适应宽度
const rootWidth = this.calculateTextWidth(config.label, this.nodeStyle.label.fontSize) + this.nodePadding * 2;
const rootWidth =
this.calculateTextWidth(config.label, this.nodeStyle.label.fontSize) +
this.nodePadding * 2;
const node = graph.addNode({
shape: 'rect',
shape: "rect",
id: config.id,
x: config.x,
y: config.y,
@ -451,11 +609,11 @@ export class X6Graph {
// 文字的样式(颜色、位置、字号等)
label: {
text: config.label,
fill: '#ffffff', // 白色文字
fill: "#ffffff", // 白色文字
fontSize: 14,
fontWeight: 'bold',
textAnchor: 'middle', // 文字水平居中
verticalAnchor: 'middle', // 文字垂直居中
fontWeight: "bold",
textAnchor: "middle", // 文字水平居中
verticalAnchor: "middle", // 文字垂直居中
},
},
data: {
@ -473,7 +631,10 @@ export class X6Graph {
/**
*
*/
addChildNode(parentId: string, config: ChildNodeConfig): { node: Node; edge: Edge } {
addChildNode(
parentId: string,
config: ChildNodeConfig,
): { node: Node; edge: Edge } {
const graph = this.getGraph();
// 校验父节点
@ -500,20 +661,20 @@ export class X6Graph {
// 创建连线
const edge = graph.addEdge({
shape: 'direct-curved-dashed-edge',
shape: "direct-curved-dashed-edge",
source: {
cell: parentId,
anchor: { name: 'right' },
anchor: { name: "right" },
},
// 目标节点(子节点)的锚点:左侧
target: {
cell: childNode.id,
anchor: { name: 'left' },
anchor: { name: "left" },
},
style: {
stroke: '#888',
stroke: "#888",
strokeWidth: 1.2,
targetMarker: { name: 'block', size: 8 },
targetMarker: { name: "block", size: 8 },
},
});
@ -524,8 +685,8 @@ export class X6Graph {
// 重排同层级节点
this.rearrangeLevelNodes(config.level);
// 新增:如果添加的是第三层级,同步重排第二层级
if (config.level === 'level3') {
this.rearrangeLevelNodes('level2');
if (config.level === "level3") {
this.rearrangeLevelNodes("level2");
}
return { node: childNode, edge: edge as Edge };
}
@ -539,28 +700,28 @@ export class X6Graph {
const centerY = (containerHeight - this.nodeStyle.height) / 2; // 画布中心点
// 获取当前层级的所有节点(按标签排序)
const levelNodes = graph.getNodes()
.filter(node => node.getData().level === level)
const levelNodes = graph
.getNodes()
.filter((node) => node.getData().level === level)
.sort((a, b) => {
const labelA = a.attr('label/text') || '';
const labelB = b.attr('label/text') || '';
const labelA = a.attr("label/text") || "";
const labelB = b.attr("label/text") || "";
return labelA.localeCompare(labelB, undefined, { numeric: true });
});
const totalNodes = levelNodes.length;
if (totalNodes === 0)
return;
if (totalNodes === 0) return;
// ======================== 第二层级:间距根据自身第三层级子节点数量动态调整 ========================
if (level === 'level2') {
// 1. 统计每个第二层级节点的第三层级子节点数量
if (level === "level2") {
// 1. 统计每个第二层级节点的第三层级子节点数量
const level2ChildCount = new Map<string, number>();
levelNodes.forEach((node) => {
const childIds = this.getChildNodeIds(node.id);
// 筛选出属于第三层级的子节点
const level3ChildCount = childIds.filter((childId) => {
const childNode = graph.getCellById(childId) as Node;
return childNode?.getData().level === 'level3';
return childNode?.getData().level === "level3";
}).length;
level2ChildCount.set(node.id, level3ChildCount);
});
@ -588,58 +749,73 @@ export class X6Graph {
// 3. 计算整体偏移量,让第二层级节点整体垂直居中
const lastNodeY = nodePositions.get(levelNodes[totalNodes - 1].id) || 0;
const totalHeight = lastNodeY - nodePositions.get(levelNodes[0].id) || 0 + this.nodeStyle.height;
const offset = centerY - (nodePositions.get(levelNodes[0].id) || 0) - totalHeight / 2;
const totalHeight =
lastNodeY - nodePositions.get(levelNodes[0].id) ||
0 + this.nodeStyle.height;
const offset =
centerY - (nodePositions.get(levelNodes[0].id) || 0) - totalHeight / 2;
// 4. 应用最终位置(加上偏移量,确保整体居中)
levelNodes.forEach((node) => {
const nodeY = (nodePositions.get(node.id) || 0) + offset;
const nodeX = this.levelXMap.get('level2') || 300;
const nodeX = this.levelXMap.get("level2") || 300;
node.setPosition(nodeX, nodeY);
});
}
// ======================== 第三层级:跟随父节点垂直分布 ========================
else if (level === 'level3') {
else if (level === "level3") {
const parentGroups = new Map<string, Node[]>();
levelNodes.forEach((node) => {
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === node.id);
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === node.id);
if (parentEdge) {
const parentId = parentEdge.getSourceCellId();
parentGroups.set(parentId, [...(parentGroups.get(parentId) || []), node]);
parentGroups.set(parentId, [
...(parentGroups.get(parentId) || []),
node,
]);
}
});
parentGroups.forEach((children, parentId) => {
const parentNode = graph.getCellById(parentId) as Node;
if (!parentNode)
return;
if (!parentNode) return;
const parentY = parentNode.getPosition().y;
const childCount = children.length;
const verticalGap = 20; // 第三层级内部固定间距
const totalChildHeight = this.nodeStyle.height * childCount + verticalGap * (childCount - 1);
const totalChildHeight =
this.nodeStyle.height * childCount + verticalGap * (childCount - 1);
const startOffset = -totalChildHeight / 2 + this.nodeStyle.height / 2;
children.forEach((child, index) => {
const childY = parentY + startOffset + (this.nodeStyle.height + verticalGap) * index;
const childX = this.levelXMap.get('level3') || 550;
const childY =
parentY +
startOffset +
(this.nodeStyle.height + verticalGap) * index;
const childX = this.levelXMap.get("level3") || 550;
child.setPosition(childX, childY);
});
});
}
else if (level === 'level4') {
// 1. 按父节点level3分组每个level3只对应1个level4
} else if (level === "level4") {
// 1. 按父节点level3分组每个level3只对应1个level4
const parentGroups = new Map<string, Node[]>();
levelNodes.forEach((node) => {
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === node.id);
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === node.id);
if (parentEdge) {
const parentId = parentEdge.getSourceCellId();
// 只保留level3的父节点确保父节点层级正确
const parentNode = graph.getCellById(parentId) as Node;
if (parentNode?.getData().level === 'level3') {
parentGroups.set(parentId, [...(parentGroups.get(parentId) || []), node]);
if (parentNode?.getData().level === "level3") {
parentGroups.set(parentId, [
...(parentGroups.get(parentId) || []),
node,
]);
}
}
});
@ -647,12 +823,11 @@ export class X6Graph {
// 2. 每个level3父节点的level4子节点放在父节点右侧固定间距
parentGroups.forEach((children, parentId) => {
const parentNode = graph.getCellById(parentId) as Node;
if (!parentNode)
return;
if (!parentNode) return;
// level4固定在level3父节点右侧X坐标=level3X + 节点宽度 + 间距20px
const parentPos = parentNode.getPosition();
const level4X = this.levelXMap.get('level4') || 800;
const level4X = this.levelXMap.get("level4") || 800;
// Y坐标与父节点完全对齐上下居中
const level4Y = parentPos.y;
@ -662,8 +837,8 @@ export class X6Graph {
targetChild.setPosition(level4X, level4Y);
// 多余节点直接删除避免多个level4
if (children.length > 1) {
children.slice(1).forEach(excessNode => excessNode.remove());
this.resetLevelCount('level4'); // 重置数量统计
children.slice(1).forEach((excessNode) => excessNode.remove());
this.resetLevelCount("level4"); // 重置数量统计
}
}
});
@ -676,8 +851,7 @@ export class X6Graph {
removeNode(nodeId: string): void {
const graph = this.getGraph();
const cell = graph.getCellById(nodeId);
if (!cell || !graph.isNode(cell))
return;
if (!cell || !graph.isNode(cell)) return;
const node = cell as Node;
const nodeLevel = node.getData().level as NodeLevel;
@ -702,33 +876,30 @@ export class X6Graph {
* @param importData - [{userName: "...", tables: [...]}]
* @param rootConfig - id label sourceOwner
*/
importData(
importData: ImportDataItem[],
rootId,
): void {
importData(importData: ImportDataItem[], rootId): void {
// 3. 遍历灌库数据,创建 level2→level3→level4 层级
importData.forEach((item) => {
// 3.1 创建 level2 节点userName 作为 label父节点是根节点
// 3.1 创建 level2 节点userName 作为 label父节点是根节点
const { node: level2Node } = this.addChildNode(rootId, {
id: uuidv4(),
label: item.userName,
level: 'level2',
level: "level2",
});
// 3.2 遍历当前 level2 的 tables创建 level3 和 level4 节点
item.tables.forEach((table) => {
// 3.2.1 创建 level3 节点tableName 作为 label父节点是当前 level2
// 3.2.1 创建 level3 节点tableName 作为 label父节点是当前 level2
const { node: level3Node } = this.addChildNode(level2Node.id, {
id: uuidv4(),
label: table.tableName,
level: 'level3',
level: "level3",
});
// 3.2.2 创建 level4 节点columnName 作为 label父节点是当前 level3
this.addChildNode(level3Node.id, {
id: uuidv4(),
label: table.columnName,
level: 'level4',
level: "level4",
});
});
});
@ -740,55 +911,72 @@ export class X6Graph {
* @param name - targetOwner字段值
* @returns /
*/
getTargetTableData(taskId: string, name: string): { data?: TargetTableItem[]; error?: string } {
getTargetTableData(
taskId: string,
name: string,
): { data?: TargetTableItem[]; error?: string } {
const graph = this.getGraph();
const result: TargetTableItem[] = [];
// 1. 找到所有 level2 节点sourceOwner 来源于 level2 的 label
const level2Nodes = graph.getNodes().filter(node => node.getData().level === 'level2');
const level2Nodes = graph
.getNodes()
.filter((node) => node.getData().level === "level2");
if (level2Nodes.length === 0) {
return { error: '未找到任何 level2 节点,无法组装数据' };
return { error: "未找到任何 level2 节点,无法组装数据" };
}
// 2. 遍历每个 level2 节点,校验子级并组装数据
for (const level2Node of level2Nodes) {
const sourceOwner = level2Node.attr('label/text') || '';
const sourceOwner = level2Node.attr("label/text") || "";
if (!sourceOwner) {
return { error: `存在未设置 label 的 level2 节点ID: ${level2Node.id}` };
return {
error: `存在未设置 label 的 level2 节点ID: ${level2Node.id}`,
};
}
// 2.1 校验 level2 是否有子级(必须包含 level3
const level2ChildIds = this.getChildNodeIds(level2Node.id);
const level3Nodes = level2ChildIds
.map(id => graph.getCellById(id) as Node)
.filter(node => node?.getData().level === 'level3');
.map((id) => graph.getCellById(id) as Node)
.filter((node) => node?.getData().level === "level3");
if (level3Nodes.length === 0) {
return { error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置` };
return {
error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置`,
};
}
// 2.2 遍历每个 level3 节点,校验子级并组装数据
for (const level3Node of level3Nodes) {
const tableName = level3Node.attr('label/text') || '';
const tableName = level3Node.attr("label/text") || "";
if (!tableName) {
return { error: `存在未设置 label 的 level3 节点(父级:${sourceOwner}` };
return {
error: `存在未设置 label 的 level3 节点(父级:${sourceOwner}`,
};
}
const syncCount = level3Node.getData().syncCount||"";
// 2.2.1 校验 level3 是否有子级(必须包含 level4
const level3ChildIds = this.getChildNodeIds(level3Node.id);
const level4Nodes = level3ChildIds
.map(id => graph.getCellById(id) as Node)
.filter(node => node?.getData().level === 'level4');
.map((id) => graph.getCellById(id) as Node)
.filter((node) => node?.getData().level === "level4");
if (level4Nodes.length === 0) {
return { error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置` };
return {
error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置`,
};
}
// 2.2.2 遍历每个 level4 节点每个level3仅取第一个有效level4避免重复
const validLevel4Node = level4Nodes[0];
const columnName = validLevel4Node.attr('label/text') || '';
const columnName = validLevel4Node.attr("label/text") || "";
if (!columnName) {
return { error: `存在未设置 label 的 level4 节点(父级:${tableName}` };
return {
error: `存在未设置 label 的 level4 节点(父级:${tableName}`,
};
}
// 2.2.3 组装单条数据
@ -798,6 +986,7 @@ export class X6Graph {
targetOwner: name, // 传入的name字段作为targetOwner
tableName,
columnName,
syncCount
});
}
}
@ -821,7 +1010,10 @@ export class X6Graph {
* ID列表
*/
private getChildNodeIds(parentId: string): string[] {
return this.getGraph().getEdges().filter(edge => edge.getSourceCellId() === parentId).map(edge => edge.getTargetCellId());
return this.getGraph()
.getEdges()
.filter((edge) => edge.getSourceCellId() === parentId)
.map((edge) => edge.getTargetCellId());
}
/**
@ -836,9 +1028,9 @@ export class X6Graph {
*/
clear(): void {
this.getGraph().clearCells();
this.levelCountMap.set('level2', 0);
this.levelCountMap.set('level3', 0);
this.levelCountMap.set('level4', 0);
this.levelCountMap.set("level2", 0);
this.levelCountMap.set("level3", 0);
this.levelCountMap.set("level4", 0);
}
/**
@ -858,7 +1050,7 @@ export class X6Graph {
*/
private getGraph(): Graph {
if (!this.graph) {
throw new Error('请先调用 init() 初始化画布');
throw new Error("请先调用 init() 初始化画布");
}
return this.graph;
}

View File

@ -1,16 +1,23 @@
<script setup lang="ts">
import { ElMessage, ElSelect } from 'element-plus';
import { v4 as uuidv4 } from 'uuid';
import { useI18n } from 'vue-i18n';
import { createTargetTables, getFileId, getMindMap, getTable, getUser, targetUser } from '@/utils/axios/sync/configTask/index';
import { ElMessage, ElSelect } from "element-plus";
import { v4 as uuidv4 } from "uuid";
import { useI18n } from "vue-i18n";
import {
createTargetTables,
getFileId,
getMindMap,
getTable,
getUser,
targetUser,
} from "@/utils/axios/sync/configTask/index";
import { X6Graph } from './antvX6';
import { X6Graph } from "./antvX6";
// data props DataProps
const props = defineProps<{
data: DataProps; // data
}>();
const emit = defineEmits(['cancel']);
const emit = defineEmits(["cancel"]);
//
interface DataProps {
taskId: string;
@ -35,17 +42,17 @@ const level3 = ref<InstanceType<typeof ElSelect> | null>(null);
const level4 = ref<InstanceType<typeof ElSelect> | null>(null);
//
const manualModeInput = ref('');
const manualModeInput = ref("");
//
const formData = reactive({
taskId: '',
sourceId: '', // mySql
sourceName: '',
taskId: "",
sourceId: "", // mySql
sourceName: "",
mode: [],
dataValue: [],
field: '',
targetUserName: '',
field: "",
targetUserName: "",
});
// Id
@ -56,7 +63,14 @@ const prevModeValues = ref<string[]>([]);
// ()
const prevDataValues = ref<string[]>([]);
// ()
const prevFieldValues = ref<string>('');
const prevFieldValues = ref<string>("");
//
const pztbslFlag = ref(false);
const pztbslValue = ref("");
// id
const nowPzTbslId = ref(null);
//
const configDataValues = ref<string[]>([]);
@ -64,7 +78,7 @@ const configDataValues = ref<string[]>([]);
//
function initGraph() {
// ID graph-container
graph.value = new X6Graph('graphContainer').init({ grid: false, zoom: true });
graph.value = new X6Graph("graphContainer").init({ grid: false, zoom: true });
// 1.
const root = graph.value.addRootNode({
@ -77,16 +91,15 @@ function initGraph() {
//
function handleModeChange(newValues: string[]) {
if (!graph.value)
return;
if (!graph.value) return;
// prevModeValues
const oldValues = prevModeValues.value;
// 1.
const added = newValues.filter(v => !oldValues.includes(v));
const added = newValues.filter((v) => !oldValues.includes(v));
// 2.
const removed = oldValues.filter(v => !newValues.includes(v));
const removed = oldValues.filter((v) => !newValues.includes(v));
//
added.forEach((value) => {
@ -95,7 +108,7 @@ function handleModeChange(newValues: string[]) {
graph.value?.addChildNode(formData.sourceId, {
id: value,
label: item.label,
level: 'level2',
level: "level2",
});
}
});
@ -111,21 +124,20 @@ function handleModeChange(newValues: string[]) {
// 仿 handleModeChange
function handleDataListChange(newValues: string[]) {
if (!graph.value)
return;
if (!graph.value) return;
// prevDataValues
const oldValues = prevDataValues.value;
// 1.
const added = newValues.filter(v => !oldValues.includes(v));
const added = newValues.filter((v) => !oldValues.includes(v));
// 2.
const removed = oldValues.filter(v => !newValues.includes(v));
const removed = oldValues.filter((v) => !newValues.includes(v));
// level3 level2
added.forEach((value) => {
// label
const item = dataList.value.find(item => item.value === value);
const item = dataList.value.find((item) => item.value === value);
if (item) {
// ID level2 level2 ID
// ID
@ -134,7 +146,7 @@ function handleDataListChange(newValues: string[]) {
graph.value?.addChildNode(parentNodeId, {
id: `${value}`, // ID
label: item.label,
level: 'level3', // level3
level: "level3", // level3
});
}
});
@ -152,11 +164,11 @@ function handleDataListChange(newValues: string[]) {
}
//
function handleFieldListChange(newValue: string) { // string[] string
console.log('当前选中值:', newValue);
function handleFieldListChange(newValue: string) {
// string[] string
console.log("当前选中值:", newValue);
if (!graph.value)
return;
if (!graph.value) return;
// 1. prevFieldValues
const oldValue = prevFieldValues.value;
@ -175,7 +187,7 @@ function handleFieldListChange(newValue: string) { // 注意:参数从 string[
//
if (newValue) {
// label
const item = fieldList.value.find(item => item.value === newValue);
const item = fieldList.value.find((item) => item.value === newValue);
if (item) {
// ID
const parentNodeId = configData.value[2].id;
@ -183,7 +195,7 @@ function handleFieldListChange(newValue: string) { // 注意:参数从 string[
graph.value.addChildNode(parentNodeId, {
id: newValue,
label: item.label,
level: 'level4', // level4
level: "level4", // level4
});
}
}
@ -202,67 +214,130 @@ onMounted(() => {
// (/)
nextTick(() => {
getTargetUserName(formData.taskId);
getMindMap({
taskId: formData.taskId,
}, (res) => {
initGraph();
getModelList((value: any) => {
console.log(value);
if (res.result.userInfos.length) {
console.log(res);
graph.value?.importData(res.result.userInfos, formData.sourceId);
res.result.userInfos.forEach((item: any) => {
modeList.value.forEach((mode: any) => {
if (mode.label === item.userName) {
formData.mode.push(mode.value);
getMindMap(
{
taskId: formData.taskId,
},
(res) => {
initGraph();
getModelList((value: any) => {
console.log(value);
if (res.result.userInfos.length) {
graph.value?.importData(res.result.userInfos, formData.sourceId);
res.result.userInfos.forEach((item: any) => {
const graphSl = graph.value?.getGraph();
//
const allNodes = graphSl.getNodes();
console.log(allNodes, "allNodes");
// label name item.name
const matchNodesData = allNodes
.map((node) => {
return {
label: node.label,
id: node.id,
};
})
.filter((item1) => {
return item1.label == item.userName;
});
const exists = modeList.value.find(
(item1: any) => item1.label === item.userName
);
if (!exists) {
const newItem = {
value: matchNodesData[0].id,
label: item.userName,
};
modeList.value.push(newItem);
formData.mode.push(newItem.value);
} else {
formData.mode.push(exists.value);
}
});
});
}
});
}
});
// mindMap.value = res.result;
});
// mindMap.value = res.result;
}
);
});
// antvX6Event
EventBusTool.getEventBus().subscribe('antvX6Event', (res: any) => {
EventBusTool.getEventBus().subscribe("antvX6Event", (res: any) => {
// icon
if (res.key === 'removeNode') {
if (res.key === "removeNode") {
console.log(res.id);
if (res.level === 'level2') {
if (res.level === "level2") {
// 3.
formData.mode = formData.mode.filter(v => v !== res.id);
prevModeValues.value = prevModeValues.value.filter(v => v !== res.id);
formData.mode = formData.mode.filter((v) => v !== res.id);
prevModeValues.value = prevModeValues.value.filter((v) => v !== res.id);
formData.dataValue = [];
formData.field = '';
}
else if (res.level === 'level3') {
formData.field = "";
} else if (res.level === "level3") {
// 3.
formData.dataValue = formData.dataValue.filter(v => v !== res.id);
prevDataValues.value = prevDataValues.value.filter(v => v !== res.id);
formData.field = '';
}
else if (res.level === 'level4') {
formData.field = '';
prevFieldValues.value = '';
formData.dataValue = formData.dataValue.filter((v) => v !== res.id);
prevDataValues.value = prevDataValues.value.filter((v) => v !== res.id);
formData.field = "";
} else if (res.level === "level4") {
formData.field = "";
prevFieldValues.value = "";
}
}
if (res.key === 'configNode') {
if (res.key === "configNode") {
configData.value = res.allParents;
configDataValues.value = [...formData.dataValue, formData.field];
console.log(
configDataValues.value,
configData.value,
"2configData.value"
);
//
if (res.level === 'level2') {
if (res.level === "level2") {
getDataListById(res.label);
}
if (res.level === 'level3') {
getFieldById(res.label);
if (res.level === "level3") {
if (res.isTbsl) {
const graphSl = graph.value?.getGraph();
const jdItem = graphSl.getCellById(res.id);
nowPzTbslId.value = res.id;
const oldSyncCount = jdItem.getData().syncCount;
pztbslFlag.value = true;
if (oldSyncCount) {
pztbslValue.value = oldSyncCount;
} else {
pztbslValue.value = "";
}
} else {
getFieldById(res.label);
}
}
}
});
});
function paTbslMethod() {
if (!pztbslValue.value) {
ElMessage({
type: "error",
message: `请配置同步数量`,
});
return;
}
const graphSl = graph.value?.getGraph();
const jdItem = graphSl.getCellById(nowPzTbslId.value);
jdItem.setData({ ...jdItem.getData(), syncCount:pztbslValue.value })
pztbslFlag.value = false;
pztbslValue.value = "";
nowPzTbslId.value = null;
}
// taskId
function getTargetUserName(taskId: any) {
targetUser({ taskId }, (res) => {
@ -299,6 +374,8 @@ function getDataListById(name: any) {
};
});
console.log(formData, "2141");
level3.value?.toggleMenu();
formData.dataValue = [];
});
@ -307,22 +384,29 @@ function getDataListById(name: any) {
function getFieldById(name: any) {
console.log(configData.value[1].label, name);
getFileId({ sourceId: formData.sourceId, username: configData.value[1].label, tableName: name }, (res) => {
fieldList.value = res.result.map((item: any) => {
return {
value: uuidv4(),
label: item,
};
});
level4.value?.toggleMenu();
formData.field = '';
});
getFileId(
{
sourceId: formData.sourceId,
username: configData.value[1].label,
tableName: name,
},
(res) => {
fieldList.value = res.result.map((item: any) => {
return {
value: uuidv4(),
label: item,
};
});
level4.value?.toggleMenu();
formData.field = "";
}
);
}
//
function handleAddMode() {
const inputVal = manualModeInput.value.trim();
if (!inputVal) {
ElMessage({ type: 'warning', message: '请输入模式名称' });
ElMessage({ type: "warning", message: "请输入模式名称" });
return;
}
// label
@ -332,97 +416,152 @@ function handleAddMode() {
if (!formData.mode.includes(exists.value)) {
formData.mode.push(exists.value);
handleModeChange([...formData.mode]);
} else {
ElMessage({ type: "warning", message: "该模式已添加" });
}
else {
ElMessage({ type: 'warning', message: '该模式已添加' });
}
}
else {
} else {
//
const newItem = { value: uuidv4(), label: inputVal };
modeList.value.push(newItem);
formData.mode.push(newItem.value);
handleModeChange([...formData.mode]);
}
manualModeInput.value = '';
manualModeInput.value = "";
}
//
function handleSave() {
if (!formData.targetUserName) {
ElMessage({
type: 'error',
type: "error",
message: `请选择目标用户名`,
});
return;
}
const data = graph.value?.getTargetTableData(formData.taskId, formData.targetUserName);
const data = graph.value?.getTargetTableData(
formData.taskId,
formData.targetUserName
);
console.log(data);
if (data) {
createTargetTables(data, (res) => {
console.log(res);
if (res === true) {
ElMessage({
type: 'success',
message: `配置数据表成功`,
});
emit('cancel');
}
}, 30 * 60 * 1000);
}
else {
createTargetTables(
data,
(res) => {
console.log(res);
if (res === true) {
ElMessage({
type: "success",
message: `配置数据表成功`,
});
emit("cancel");
}
},
30 * 60 * 1000
);
} else {
ElMessage({
type: 'error',
type: "error",
message: `配置有误,请检查`,
});
}
}
//
function handleCancel() {
emit('cancel');
emit("cancel");
}
</script>
<template>
<div class="dataTable">
<ElForm :inline="true" :model="formData" class="custom-form" style="margin-bottom: 20px;">
<ElForm
:inline="true"
:model="formData"
class="custom-form"
style="margin-bottom: 20px"
>
<ElFormItem :label="t('database')">
<ElInput v-model="formData.sourceName" disabled style="width: 180px" />
</ElFormItem>
<ElFormItem :label="t('mode')">
<!-- <ElFormItem :label="t('mode')">
<ElSelect v-model="formData.mode" style="width: 220px" multiple :max-collapse-tags="0" collapse-tags
collapse-tags-tooltip filterable :placeholder="t('pleaseSelect')" class="custom-select"
allow-create default-first-option :reserve-keyword="false"
@change="handleModeChange">
<ElOption v-for="(item, index) in modeList" :key="index" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<!--<ElFormItem label="手动输入模式" v-show="!formData.mode || !formData.mode.length">
</ElFormItem> -->
<!-- <ElFormItem label="手动输入模式" v-show="!formData.mode || !formData.mode.length">
<div style="display: flex; align-items: center;">
<ElInput v-model="manualModeInput" style="width: 160px" placeholder="输入模式名" @keyup.enter="handleAddMode" />
<ElButton type="primary" style="margin-left: 4px; height: 34px; border-radius: 4px;" @click="handleAddMode">
添加
</ElButton>
</div>
</ElFormItem>-->
</ElFormItem> -->
<ElFormItem :label="t('mode')">
<ElInput
v-model="manualModeInput"
style="width: 160px"
placeholder="输入模式名"
@keyup.enter="handleAddMode"
/>
</ElFormItem>
<ElFormItem :label="t('dataTable')">
<ElSelect ref="level3" v-model="formData.dataValue" style="width: 220px" multiple :max-collapse-tags="0"
collapse-tags collapse-tags-tooltip filterable :placeholder="t('pleaseSelect')" class="custom-select"
@change="handleDataListChange">
<ElOption v-for="(item, index) in dataList" :key="index" :label="item.label" :value="item.value" />
<ElSelect
ref="level3"
v-model="formData.dataValue"
style="width: 220px"
multiple
:max-collapse-tags="0"
collapse-tags
collapse-tags-tooltip
filterable
:placeholder="t('pleaseSelect')"
class="custom-select"
@change="handleDataListChange"
>
<ElOption
v-for="(item, index) in dataList"
:key="index"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('field')">
<ElSelect ref="level4" v-model="formData.field" style="width: 220px" filterable :placeholder="t('pleaseSelect')"
class="custom-select" @change="handleFieldListChange">
<ElOption v-for="(item, index) in fieldList" :key="index" :label="item.label" :value="item.value"
:disabled="formData.dataValue.length === 0" />
<ElSelect
ref="level4"
v-model="formData.field"
style="width: 220px"
filterable
:placeholder="t('pleaseSelect')"
class="custom-select"
@change="handleFieldListChange"
>
<ElOption
v-for="(item, index) in fieldList"
:key="index"
:label="item.label"
:value="item.value"
:disabled="formData.dataValue.length === 0"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="t('targetUserName')">
<ElSelect v-model="formData.targetUserName" style="width: 220px" filterable :placeholder="t('pleaseSelect')"
class="custom-select">
<ElOption v-for="(item, index) in targetUserNameList" :key="index" :label="item.label" :value="item.value" />
<ElSelect
v-model="formData.targetUserName"
style="width: 220px"
filterable
:placeholder="t('pleaseSelect')"
class="custom-select"
>
<ElOption
v-for="(item, index) in targetUserNameList"
:key="index"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElForm>
@ -432,13 +571,32 @@ function handleCancel() {
</div>
<div class="btns">
<div class="save" @click="handleSave">
保存
</div>
<div class="close" @click="handleCancel">
取消
</div>
<div class="save" @click="handleSave">保存</div>
<div class="close" @click="handleCancel">取消</div>
</div>
<Dialog
v-model="pztbslFlag"
title="配置同步数量"
width="20vw"
class="config-dialog"
>
<el-input
v-model="pztbslValue"
style="width: 240px"
type="number"
placeholder="请配置同步数量"
/>
<template #footer>
<ElButton
type="text"
style="color: #fff; cursor: no-drop"
@click="paTbslMethod"
>
配置
</ElButton>
</template>
</Dialog>
</div>
</template>
@ -447,6 +605,14 @@ function handleCancel() {
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
::v-deep .config-dialog {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.custom-form {
margin-bottom: 20px;

View File

@ -1,9 +1,9 @@
import type { Edge, Node, NodeConfig } from '@antv/x6';
import { Graph } from '@antv/x6';
import { v4 as uuidv4 } from 'uuid';
import type { Edge, Node, NodeConfig } from "@antv/x6";
import { Graph } from "@antv/x6";
import { v4 as uuidv4 } from "uuid";
// 层级类型定义
export type NodeLevel = 'level2' | 'level3' | 'level4';
export type NodeLevel = "level2" | "level3" | "level4";
// 目标灌库数据的类型定义
interface ImportDataItem {
@ -50,14 +50,14 @@ export class X6Graph {
private readonly nodeStyle = {
width: 100,
height: 40,
fill: '#0a4a5c', // 深色背景(示例中的深青色)
stroke: '#0a4a5c', // 边框与背景同色,视觉上隐藏边框
fill: "#0a4a5c", // 深色背景(示例中的深青色)
stroke: "#0a4a5c", // 边框与背景同色,视觉上隐藏边框
strokeWidth: 2,
radius: 4,
label: {
fill: '#93CDD5', // 白色文字
fill: "#93CDD5", // 白色文字
fontSize: 14,
fontWeight: 'bold', // 可选:文字加粗
fontWeight: "bold", // 可选:文字加粗
},
};
@ -69,16 +69,16 @@ export class X6Graph {
// 层级X坐标映射
private readonly levelXMap = new Map<NodeLevel, number>([
['level2', 300],
['level3', 550],
['level4', 800],
["level2", 300],
["level3", 550],
["level4", 800],
]);
// 层级节点数量记录
private readonly levelCountMap = new Map<NodeLevel, number>([
['level2', 0],
['level3', 0],
['level4', 0],
["level2", 0],
["level3", 0],
["level4", 0],
]);
constructor(containerId: string) {
@ -87,7 +87,7 @@ export class X6Graph {
}
private createMenuElement() {
this.menuElement = document.createElement('div');
this.menuElement = document.createElement("div");
this.menuElement.style.cssText = `
position: absolute;
width: 100px;
@ -102,16 +102,16 @@ export class X6Graph {
`;
// 1. 新增“配置”菜单项
const configItem = document.createElement('div');
const configItem = document.createElement("div");
configItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
border-bottom: 1px solid #f0f0f0; /* 分隔线,区分两个选项 */
`;
configItem.textContent = '配置';
configItem.addEventListener('click', () => {
const nodeId = this.menuElement?.getAttribute('data-node-id');
configItem.textContent = "配置";
configItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
@ -121,35 +121,41 @@ export class X6Graph {
// 2. 高亮样式(保持原有)
targetNode.attr({
body: {
stroke: 'rgb(12,129,123)',
stroke: "rgb(12,129,123)",
strokeWidth: 2,
strokeDasharray: '5, 5',
strokeDasharray: "5, 5",
},
});
// ======================== 新增:获取所有父级信息 ========================
// 1. 定义追溯父级的方法
const getAllParents = (targetNodeId: string): Array<{ id: string; label: string; level?: NodeLevel }> => {
const parents: Array<{ id: string; label: string; level?: NodeLevel }> = [];
const getAllParents = (
targetNodeId: string,
): Array<{ id: string; label: string; level?: NodeLevel }> => {
const parents: Array<{
id: string;
label: string;
level?: NodeLevel;
}> = [];
let currentNodeId = targetNodeId;
// 循环追溯:直到找不到父节点(根节点)
while (true) {
// 找到指向当前节点的连线(来源即父节点)
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === currentNodeId);
if (!parentEdge)
break; // 无父节点,终止循环
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === currentNodeId);
if (!parentEdge) break; // 无父节点,终止循环
// 获取父节点ID和父节点实例
const parentNodeId = parentEdge.getSourceCellId();
const parentNode = graph.getCellById(parentNodeId) as Node;
if (!parentNode)
break;
if (!parentNode) break;
// 收集父节点信息ID、标签、层级
parents.push({
id: parentNodeId,
label: parentNode.attr('label/text') || '', // 节点显示的文字
label: parentNode.attr("label/text") || "", // 节点显示的文字
level: parentNode.getData().level, // 父节点的层级
});
@ -168,19 +174,19 @@ export class X6Graph {
// 关键添加当前节点信息含label拼接成“父级+当前节点”的完整链
const currentNodeInfo = {
id: nodeId,
label: targetNode.attr('label/text') || '', // 从当前节点获取label
label: targetNode.attr("label/text") || "", // 从当前节点获取label
level: nodeLevel,
};
allParentsOrdered = [...allParentsOrdered, currentNodeInfo];
console.log('目标节点(父级+自身)完整链:', allParentsOrdered);
console.log("目标节点(父级+自身)完整链:", allParentsOrdered);
// ========================================================================
// 3. 事件总线传递:携带完整链信息
EventBusTool.getEventBus().publish('antvX6Event', {
key: 'configNode',
EventBusTool.getEventBus().publish("antvX6Event", {
key: "configNode",
id: nodeId,
label: targetNode.attr('label/text') || '',
label: targetNode.attr("label/text") || "",
level: nodeLevel,
allParents: allParentsOrdered, // 包含父级+当前节点均有label
});
@ -188,23 +194,23 @@ export class X6Graph {
this.hideMenu();
});
// 配置项悬停效果
configItem.addEventListener('mouseenter', () => {
configItem.style.background = '#f0f0f0';
configItem.addEventListener("mouseenter", () => {
configItem.style.background = "#f0f0f0";
});
configItem.addEventListener('mouseleave', () => {
configItem.style.background = 'white';
configItem.addEventListener("mouseleave", () => {
configItem.style.background = "white";
});
// 2. 原有“删除”菜单项(保持不变,调整顺序在配置项下方)
const deleteItem = document.createElement('div');
const deleteItem = document.createElement("div");
deleteItem.style.cssText = `
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
`;
deleteItem.textContent = '删除';
deleteItem.addEventListener('click', () => {
const nodeId = this.menuElement?.getAttribute('data-node-id');
deleteItem.textContent = "删除";
deleteItem.addEventListener("click", () => {
const nodeId = this.menuElement?.getAttribute("data-node-id");
if (nodeId) {
// 获取图形实例和目标节点(和配置项逻辑一致)
const graph = this.getGraph();
@ -212,8 +218,8 @@ export class X6Graph {
// 从节点数据中提取level
const nodeLevel = targetNode.getData().level;
this.removeNode(nodeId);
EventBusTool.getEventBus().publish('antvX6Event', {
key: 'removeNode',
EventBusTool.getEventBus().publish("antvX6Event", {
key: "removeNode",
id: nodeId,
level: nodeLevel, // 新增传递level信息
});
@ -221,11 +227,11 @@ export class X6Graph {
this.hideMenu();
});
// 删除项悬停效果
deleteItem.addEventListener('mouseenter', () => {
deleteItem.style.background = '#f0f0f0';
deleteItem.addEventListener("mouseenter", () => {
deleteItem.style.background = "#f0f0f0";
});
deleteItem.addEventListener('mouseleave', () => {
deleteItem.style.background = 'white';
deleteItem.addEventListener("mouseleave", () => {
deleteItem.style.background = "white";
});
// 3. 将两个菜单项添加到菜单容器(配置项在上,删除项在下)
@ -238,12 +244,11 @@ export class X6Graph {
*
*/
private showMenu(nodeId: string, x: number, y: number) {
if (!this.menuElement)
return;
this.menuElement.setAttribute('data-node-id', nodeId);
if (!this.menuElement) return;
this.menuElement.setAttribute("data-node-id", nodeId);
this.menuElement.style.left = `${x}px`;
this.menuElement.style.top = `${y}px`;
this.menuElement.style.display = 'block';
this.menuElement.style.display = "block";
}
/**
@ -251,7 +256,7 @@ export class X6Graph {
*/
private hideMenu() {
if (this.menuElement) {
this.menuElement.style.display = 'none';
this.menuElement.style.display = "none";
}
}
@ -269,14 +274,14 @@ export class X6Graph {
container,
width: container.clientWidth,
height: container.clientHeight,
grid: options.grid ?? { size: 10, visible: false, type: 'dot' },
grid: options.grid ?? { size: 10, visible: false, type: "dot" },
mousewheel: options.zoom ?? {
enabled: true,
modifiers: 'ctrl',
modifiers: "ctrl",
minScale: 0.5,
maxScale: 2,
},
panning: { enabled: true, modifier: 'shift' },
panning: { enabled: true, modifier: "shift" },
snapline: true,
// 关键:完善选择配置,确保单击和框选生效
selecting: {
@ -285,20 +290,24 @@ export class X6Graph {
});
// 注册“直接曲线虚线”自定义边
Graph.registerEdge('direct-curved-dashed-edge', {
inherit: 'edge',
// 连接器:使用 smooth平滑曲线无额外拐点
connector: { name: 'smooth' },
attrs: {
line: {
targetMarker: '', // 移除目标标记(可选)
stroke: '#A2B1C3', // 线条颜色
strokeWidth: 2, // 线条宽度
strokeDasharray: '5, 5', // 虚线线段5px + 间隔5px
Graph.registerEdge(
"direct-curved-dashed-edge",
{
inherit: "edge",
// 连接器:使用 smooth平滑曲线无额外拐点
connector: { name: "smooth" },
attrs: {
line: {
targetMarker: "", // 移除目标标记(可选)
stroke: "#A2B1C3", // 线条颜色
strokeWidth: 2, // 线条宽度
strokeDasharray: "5, 5", // 虚线线段5px + 间隔5px
},
},
zIndex: 0, // 层级(避免遮挡节点)
},
zIndex: 0, // 层级(避免遮挡节点)
}, true);
true,
);
// 绑定右键菜单事件
this.bindRightClickEvent();
@ -313,7 +322,7 @@ export class X6Graph {
const graph = this.getGraph();
// 节点右键点击
graph.on('node:contextmenu', (args) => {
graph.on("node:contextmenu", (args) => {
args.e.preventDefault(); // 阻止浏览器默认右键菜单
const { cell, e } = args;
const node = cell as Node;
@ -331,13 +340,13 @@ export class X6Graph {
});
// 画布空白处右键点击(隐藏菜单)
graph.on('blank:contextmenu', (args) => {
graph.on("blank:contextmenu", (args) => {
args.e.preventDefault();
this.hideMenu();
});
// 点击其他区域隐藏菜单
document.addEventListener('click', () => {
document.addEventListener("click", () => {
this.hideMenu();
});
}
@ -368,7 +377,7 @@ export class X6Graph {
private addNode(config: SimpleNodeConfig): Node {
const graph = this.getGraph();
const node = graph.addNode({
shape: 'rect',
shape: "rect",
id: config.id,
x: config.x,
y: config.y,
@ -387,11 +396,11 @@ export class X6Graph {
// 文字的样式(颜色、位置、字号等)
label: {
text: config.label,
fill: '#ffffff', // 白色文字
fill: "#ffffff", // 白色文字
fontSize: 14,
fontWeight: 'bold',
textAnchor: 'middle', // 文字水平居中
verticalAnchor: 'middle', // 文字垂直居中
fontWeight: "bold",
textAnchor: "middle", // 文字水平居中
verticalAnchor: "middle", // 文字垂直居中
},
},
data: {
@ -409,7 +418,10 @@ export class X6Graph {
/**
*
*/
addChildNode(parentId: string, config: ChildNodeConfig): { node: Node; edge: Edge } {
addChildNode(
parentId: string,
config: ChildNodeConfig,
): { node: Node; edge: Edge } {
const graph = this.getGraph();
// 校验父节点
@ -436,20 +448,20 @@ export class X6Graph {
// 创建连线
const edge = graph.addEdge({
shape: 'direct-curved-dashed-edge',
shape: "direct-curved-dashed-edge",
source: {
cell: parentId,
anchor: { name: 'right' },
anchor: { name: "right" },
},
// 目标节点(子节点)的锚点:左侧
target: {
cell: childNode.id,
anchor: { name: 'left' },
anchor: { name: "left" },
},
style: {
stroke: '#888',
stroke: "#888",
strokeWidth: 1.2,
targetMarker: { name: 'block', size: 8 },
targetMarker: { name: "block", size: 8 },
},
});
@ -460,8 +472,8 @@ export class X6Graph {
// 重排同层级节点
this.rearrangeLevelNodes(config.level);
// 新增:如果添加的是第三层级,同步重排第二层级
if (config.level === 'level3') {
this.rearrangeLevelNodes('level2');
if (config.level === "level3") {
this.rearrangeLevelNodes("level2");
}
return { node: childNode, edge: edge as Edge };
}
@ -475,28 +487,28 @@ export class X6Graph {
const centerY = (containerHeight - this.nodeStyle.height) / 2; // 画布中心点
// 获取当前层级的所有节点(按标签排序)
const levelNodes = graph.getNodes()
.filter(node => node.getData().level === level)
const levelNodes = graph
.getNodes()
.filter((node) => node.getData().level === level)
.sort((a, b) => {
const labelA = a.attr('label/text') || '';
const labelB = b.attr('label/text') || '';
const labelA = a.attr("label/text") || "";
const labelB = b.attr("label/text") || "";
return labelA.localeCompare(labelB, undefined, { numeric: true });
});
const totalNodes = levelNodes.length;
if (totalNodes === 0)
return;
if (totalNodes === 0) return;
// ======================== 第二层级:间距根据自身第三层级子节点数量动态调整 ========================
if (level === 'level2') {
// 1. 统计每个第二层级节点的第三层级子节点数量
if (level === "level2") {
// 1. 统计每个第二层级节点的第三层级子节点数量
const level2ChildCount = new Map<string, number>();
levelNodes.forEach((node) => {
const childIds = this.getChildNodeIds(node.id);
// 筛选出属于第三层级的子节点
const level3ChildCount = childIds.filter((childId) => {
const childNode = graph.getCellById(childId) as Node;
return childNode?.getData().level === 'level3';
return childNode?.getData().level === "level3";
}).length;
level2ChildCount.set(node.id, level3ChildCount);
});
@ -524,58 +536,73 @@ export class X6Graph {
// 3. 计算整体偏移量,让第二层级节点整体垂直居中
const lastNodeY = nodePositions.get(levelNodes[totalNodes - 1].id) || 0;
const totalHeight = lastNodeY - nodePositions.get(levelNodes[0].id) || 0 + this.nodeStyle.height;
const offset = centerY - (nodePositions.get(levelNodes[0].id) || 0) - totalHeight / 2;
const totalHeight =
lastNodeY - nodePositions.get(levelNodes[0].id) ||
0 + this.nodeStyle.height;
const offset =
centerY - (nodePositions.get(levelNodes[0].id) || 0) - totalHeight / 2;
// 4. 应用最终位置(加上偏移量,确保整体居中)
levelNodes.forEach((node) => {
const nodeY = (nodePositions.get(node.id) || 0) + offset;
const nodeX = this.levelXMap.get('level2') || 300;
const nodeX = this.levelXMap.get("level2") || 300;
node.setPosition(nodeX, nodeY);
});
}
// ======================== 第三层级:跟随父节点垂直分布 ========================
else if (level === 'level3') {
else if (level === "level3") {
const parentGroups = new Map<string, Node[]>();
levelNodes.forEach((node) => {
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === node.id);
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === node.id);
if (parentEdge) {
const parentId = parentEdge.getSourceCellId();
parentGroups.set(parentId, [...(parentGroups.get(parentId) || []), node]);
parentGroups.set(parentId, [
...(parentGroups.get(parentId) || []),
node,
]);
}
});
parentGroups.forEach((children, parentId) => {
const parentNode = graph.getCellById(parentId) as Node;
if (!parentNode)
return;
if (!parentNode) return;
const parentY = parentNode.getPosition().y;
const childCount = children.length;
const verticalGap = 20; // 第三层级内部固定间距
const totalChildHeight = this.nodeStyle.height * childCount + verticalGap * (childCount - 1);
const totalChildHeight =
this.nodeStyle.height * childCount + verticalGap * (childCount - 1);
const startOffset = -totalChildHeight / 2 + this.nodeStyle.height / 2;
children.forEach((child, index) => {
const childY = parentY + startOffset + (this.nodeStyle.height + verticalGap) * index;
const childX = this.levelXMap.get('level3') || 550;
const childY =
parentY +
startOffset +
(this.nodeStyle.height + verticalGap) * index;
const childX = this.levelXMap.get("level3") || 550;
child.setPosition(childX, childY);
});
});
}
else if (level === 'level4') {
// 1. 按父节点level3分组每个level3只对应1个level4
} else if (level === "level4") {
// 1. 按父节点level3分组每个level3只对应1个level4
const parentGroups = new Map<string, Node[]>();
levelNodes.forEach((node) => {
const parentEdge = graph.getEdges().find(edge => edge.getTargetCellId() === node.id);
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === node.id);
if (parentEdge) {
const parentId = parentEdge.getSourceCellId();
// 只保留level3的父节点确保父节点层级正确
const parentNode = graph.getCellById(parentId) as Node;
if (parentNode?.getData().level === 'level3') {
parentGroups.set(parentId, [...(parentGroups.get(parentId) || []), node]);
if (parentNode?.getData().level === "level3") {
parentGroups.set(parentId, [
...(parentGroups.get(parentId) || []),
node,
]);
}
}
});
@ -583,12 +610,11 @@ export class X6Graph {
// 2. 每个level3父节点的level4子节点放在父节点右侧固定间距
parentGroups.forEach((children, parentId) => {
const parentNode = graph.getCellById(parentId) as Node;
if (!parentNode)
return;
if (!parentNode) return;
// level4固定在level3父节点右侧X坐标=level3X + 节点宽度 + 间距20px
const parentPos = parentNode.getPosition();
const level4X = this.levelXMap.get('level4') || 800;
const level4X = this.levelXMap.get("level4") || 800;
// Y坐标与父节点完全对齐上下居中
const level4Y = parentPos.y;
@ -598,8 +624,8 @@ export class X6Graph {
targetChild.setPosition(level4X, level4Y);
// 多余节点直接删除避免多个level4
if (children.length > 1) {
children.slice(1).forEach(excessNode => excessNode.remove());
this.resetLevelCount('level4'); // 重置数量统计
children.slice(1).forEach((excessNode) => excessNode.remove());
this.resetLevelCount("level4"); // 重置数量统计
}
}
});
@ -612,8 +638,7 @@ export class X6Graph {
removeNode(nodeId: string): void {
const graph = this.getGraph();
const cell = graph.getCellById(nodeId);
if (!cell || !graph.isNode(cell))
return;
if (!cell || !graph.isNode(cell)) return;
const node = cell as Node;
const nodeLevel = node.getData().level as NodeLevel;
@ -638,33 +663,30 @@ export class X6Graph {
* @param importData - [{userName: "...", tables: [...]}]
* @param rootConfig - id label sourceOwner
*/
importData(
importData: ImportDataItem[],
rootId,
): void {
importData(importData: ImportDataItem[], rootId): void {
// 3. 遍历灌库数据,创建 level2→level3→level4 层级
importData.forEach((item) => {
// 3.1 创建 level2 节点userName 作为 label父节点是根节点
// 3.1 创建 level2 节点userName 作为 label父节点是根节点
const { node: level2Node } = this.addChildNode(rootId, {
id: uuidv4(),
label: item.userName,
level: 'level2',
level: "level2",
});
// 3.2 遍历当前 level2 的 tables创建 level3 和 level4 节点
item.tables.forEach((table) => {
// 3.2.1 创建 level3 节点tableName 作为 label父节点是当前 level2
// 3.2.1 创建 level3 节点tableName 作为 label父节点是当前 level2
const { node: level3Node } = this.addChildNode(level2Node.id, {
id: uuidv4(),
label: table.tableName,
level: 'level3',
level: "level3",
});
// 3.2.2 创建 level4 节点columnName 作为 label父节点是当前 level3
this.addChildNode(level3Node.id, {
id: uuidv4(),
label: table.columnName,
level: 'level4',
level: "level4",
});
});
});
@ -676,55 +698,70 @@ export class X6Graph {
* @param name - targetOwner字段值
* @returns /
*/
getTargetTableData(taskId: string, name: string): { data?: TargetTableItem[]; error?: string } {
getTargetTableData(
taskId: string,
name: string,
): { data?: TargetTableItem[]; error?: string } {
const graph = this.getGraph();
const result: TargetTableItem[] = [];
// 1. 找到所有 level2 节点sourceOwner 来源于 level2 的 label
const level2Nodes = graph.getNodes().filter(node => node.getData().level === 'level2');
const level2Nodes = graph
.getNodes()
.filter((node) => node.getData().level === "level2");
if (level2Nodes.length === 0) {
return { error: '未找到任何 level2 节点,无法组装数据' };
return { error: "未找到任何 level2 节点,无法组装数据" };
}
// 2. 遍历每个 level2 节点,校验子级并组装数据
for (const level2Node of level2Nodes) {
const sourceOwner = level2Node.attr('label/text') || '';
const sourceOwner = level2Node.attr("label/text") || "";
if (!sourceOwner) {
return { error: `存在未设置 label 的 level2 节点ID: ${level2Node.id}` };
return {
error: `存在未设置 label 的 level2 节点ID: ${level2Node.id}`,
};
}
// 2.1 校验 level2 是否有子级(必须包含 level3
const level2ChildIds = this.getChildNodeIds(level2Node.id);
const level3Nodes = level2ChildIds
.map(id => graph.getCellById(id) as Node)
.filter(node => node?.getData().level === 'level3');
.map((id) => graph.getCellById(id) as Node)
.filter((node) => node?.getData().level === "level3");
if (level3Nodes.length === 0) {
return { error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置` };
return {
error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置`,
};
}
// 2.2 遍历每个 level3 节点,校验子级并组装数据
for (const level3Node of level3Nodes) {
const tableName = level3Node.attr('label/text') || '';
const tableName = level3Node.attr("label/text") || "";
if (!tableName) {
return { error: `存在未设置 label 的 level3 节点(父级:${sourceOwner}` };
return {
error: `存在未设置 label 的 level3 节点(父级:${sourceOwner}`,
};
}
// 2.2.1 校验 level3 是否有子级(必须包含 level4
const level3ChildIds = this.getChildNodeIds(level3Node.id);
const level4Nodes = level3ChildIds
.map(id => graph.getCellById(id) as Node)
.filter(node => node?.getData().level === 'level4');
.map((id) => graph.getCellById(id) as Node)
.filter((node) => node?.getData().level === "level4");
if (level4Nodes.length === 0) {
return { error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置` };
return {
error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置`,
};
}
// 2.2.2 遍历每个 level4 节点每个level3仅取第一个有效level4避免重复
const validLevel4Node = level4Nodes[0];
const columnName = validLevel4Node.attr('label/text') || '';
const columnName = validLevel4Node.attr("label/text") || "";
if (!columnName) {
return { error: `存在未设置 label 的 level4 节点(父级:${tableName}` };
return {
error: `存在未设置 label 的 level4 节点(父级:${tableName}`,
};
}
// 2.2.3 组装单条数据
@ -757,7 +794,10 @@ export class X6Graph {
* ID列表
*/
private getChildNodeIds(parentId: string): string[] {
return this.getGraph().getEdges().filter(edge => edge.getSourceCellId() === parentId).map(edge => edge.getTargetCellId());
return this.getGraph()
.getEdges()
.filter((edge) => edge.getSourceCellId() === parentId)
.map((edge) => edge.getTargetCellId());
}
/**
@ -772,9 +812,9 @@ export class X6Graph {
*/
clear(): void {
this.getGraph().clearCells();
this.levelCountMap.set('level2', 0);
this.levelCountMap.set('level3', 0);
this.levelCountMap.set('level4', 0);
this.levelCountMap.set("level2", 0);
this.levelCountMap.set("level3", 0);
this.levelCountMap.set("level4", 0);
}
/**
@ -794,7 +834,7 @@ export class X6Graph {
*/
private getGraph(): Graph {
if (!this.graph) {
throw new Error('请先调用 init() 初始化画布');
throw new Error("请先调用 init() 初始化画布");
}
return this.graph;
}