SourceTermAnalysisSystem_vue/src/pages/sjtb/configTask/dataTable/antvX6.ts
2026-08-10 16:26:33 +08:00

1126 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
// 目标灌库数据的类型定义
interface ImportDataItem {
userName: string; // 对应 level2 节点的 label
tables: Array<{
tableName: string; // 对应 level3 节点的 label
columnName: string; // 对应 level4 节点的 label
}>;
}
// 子节点配置接口
interface ChildNodeConfig {
id: string;
label: string;
level: NodeLevel;
x?: number;
businessId?: string;
}
// 根节点配置接口
interface RootNodeConfig {
id: string;
label: string;
businessId?: string;
x?: number;
}
// 普通节点配置(内部使用)
interface SimpleNodeConfig {
id: string;
x: number;
y: number;
label: string;
level?: NodeLevel;
businessId?: string;
}
export class X6Graph {
private graph: Graph | null = null;
private containerId: string;
private menuElement: HTMLDivElement | null = null; // 右键菜单
private readonly nodePadding = 20;
// 节点样式原生rect节点确保可见
private readonly nodeStyle = {
width: 100,
height: 40,
fill: "#0a4a5c", // 深色背景(示例中的深青色)
stroke: "#0a4a5c", // 边框与背景同色,视觉上隐藏边框
strokeWidth: 2,
radius: 4,
label: {
fill: "#93CDD5", // 白色文字
fontSize: 14,
fontWeight: "bold", // 可选:文字加粗
},
};
// 固定尺寸
private readonly nodeSize = {
width: 200,
height: 40,
};
// 层级X坐标映射
private readonly levelXMap = new Map<NodeLevel, number>([
["level2", 300],
["level3", 550],
["level4", 1000],
]);
// 层级节点数量记录
private readonly levelCountMap = new Map<NodeLevel, number>([
["level2", 0],
["level3", 0],
["level4", 0],
]);
constructor(containerId: string) {
this.containerId = containerId;
this.createMenuElement(); // 初始化右键菜单
}
private createMenuElement() {
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;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 1000;
display: none;
font-family: sans-serif;
`;
// 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; /* 分隔线,区分两个选项 */
`;
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;
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
});
}
this.hideMenu();
});
// 配置菜单悬停效果
pzItem.addEventListener("mouseenter", () => {
pzItem.style.background = "#f0f0f0";
});
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. 配置同步数量
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");
if (nodeId) {
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
const nodeLevel = targetNode.getData().level;
// 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("mouseleave", () => {
syncNumItem.style.background = "white";
});
// 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");
if (nodeId) {
// 获取图形实例和目标节点(和配置项逻辑一致)
const graph = this.getGraph();
const targetNode = graph.getCellById(nodeId) as Node;
// 从节点数据中提取level
const nodeLevel = targetNode.getData().level;
this.removeNode(nodeId);
EventBusTool.getEventBus().publish("antvX6Event", {
key: "removeNode",
id: nodeId,
level: nodeLevel, // 新增传递level信息
});
}
this.hideMenu();
});
// 删除项悬停效果
deleteItem.addEventListener("mouseenter", () => {
deleteItem.style.background = "#f0f0f0";
});
deleteItem.addEventListener("mouseleave", () => {
deleteItem.style.background = "white";
});
// 3. 将两个菜单项添加到菜单容器(配置项在上,删除项在下)
this.menuElement.appendChild(pzItem);
this.menuElement.appendChild(configItem);
this.menuElement.appendChild(syncNumItem);
this.menuElement.appendChild(deleteItem);
document.body.appendChild(this.menuElement);
}
/**
* 显示右键菜单
*/
private showMenu(nodeId: string, x: number, y: number) {
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";
}
/**
* 隐藏右键菜单
*/
private hideMenu() {
if (this.menuElement) {
this.menuElement.style.display = "none";
}
}
/**
* 初始化画布使用原生rect节点
*/
init(options: Partial<{ grid: boolean; zoom: boolean }> = {}): this {
const container = document.getElementById(this.containerId);
if (!container) {
throw new Error(`画布容器 "${this.containerId}" 不存在`);
}
// 初始化画布
this.graph = new Graph({
container,
width: container.clientWidth,
height: container.clientHeight,
grid: options.grid ?? { size: 10, visible: false, type: "dot" },
mousewheel: options.zoom ?? {
enabled: true,
modifiers: "ctrl",
minScale: 0.5,
maxScale: 2,
},
panning: { enabled: true, modifier: "shift" },
snapline: true,
// 关键:完善选择配置,确保单击和框选生效
selecting: {
enabled: true, // 开启选择交互
},
});
// 注册“直接曲线虚线”自定义边
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, // 层级(避免遮挡节点)
},
true,
);
// 绑定右键菜单事件
this.bindRightClickEvent();
return this;
}
/**
* 绑定右键点击事件
*/
private bindRightClickEvent() {
const graph = this.getGraph();
// 节点右键点击
graph.on("node:contextmenu", (args) => {
args.e.preventDefault(); // 阻止浏览器默认右键菜单
const { cell, e } = args;
const node = cell as Node;
const nodeData = node.getData(); // 获取节点数据
console.log(nodeData);
// 关键判断:如果节点无 level根节点直接返回不显示菜单
if (!nodeData.level) {
return;
}
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) => {
args.e.preventDefault();
this.hideMenu();
});
// 点击其他区域隐藏菜单
document.addEventListener("click", () => {
this.hideMenu();
});
}
/**
* 计算文字宽度(用于自适应节点宽度)
*/
private calculateTextWidth(text: string, fontSize = 14): number {
// 创建临时元素计算文字宽度
const tempSpan = document.createElement("span");
tempSpan.style.cssText = `
position: absolute;
visibility: hidden;
white-space: nowrap;
font-size: ${fontSize}px;
font-weight: bold;
font-family: sans-serif;
`;
tempSpan.textContent = text;
document.body.appendChild(tempSpan);
const width = tempSpan.offsetWidth;
document.body.removeChild(tempSpan);
return width;
}
/**
* 添加根节点原生rect节点
*/
addRootNode(config: RootNodeConfig): Node {
const graph = this.getGraph();
const containerHeight = graph.container.clientHeight;
const centerY = (containerHeight - this.nodeStyle.height) / 2;
const rootX = config.x ?? 50;
console.log(config.label);
return this.addNode({
id: config.id,
x: rootX,
y: centerY,
label: config.label,
businessId: config.businessId,
});
}
/**
* 添加普通节点
*/
private addNode(config: SimpleNodeConfig): Node {
const graph = this.getGraph();
// 计算根节点自适应宽度
const rootWidth =
this.calculateTextWidth(config.label, this.nodeStyle.label.fontSize) +
this.nodePadding * 2;
const node = graph.addNode({
shape: "rect",
id: config.id,
x: config.x,
y: config.y,
width: rootWidth,
height: this.nodeSize.height,
label: config.label,
attrs: {
// 矩形主体的样式(背景、边框、圆角等)
body: {
fill: this.nodeStyle.fill, // 深青色背景
stroke: this.nodeStyle.stroke, // 边框与背景同色(视觉隐藏)
strokeWidth: 1,
rx: 20, // 圆角
ry: 20,
},
// 文字的样式(颜色、位置、字号等)
label: {
text: config.label,
fill: "#ffffff", // 白色文字
fontSize: 14,
fontWeight: "bold",
textAnchor: "middle", // 文字水平居中
verticalAnchor: "middle", // 文字垂直居中
},
},
data: {
businessId: config.businessId,
level: config.level,
},
});
if (!graph.isNode(node)) {
throw new Error(`创建节点 "${config.id}" 失败`);
}
return node as Node;
}
/**
* 添加子节点(按层级动态分配位置)
*/
addChildNode(
parentId: string,
config: ChildNodeConfig,
skipRearrange = false,
): { node: Node; edge: Edge } {
const graph = this.getGraph();
// 校验父节点
const parentCell = graph.getCellById(parentId);
if (!parentCell || !graph.isNode(parentCell)) {
throw new Error(`父节点 "${parentId}" 不存在或无效`);
}
// 确定X位置
const levelX = this.levelXMap.get(config.level) ?? 300;
const nodeX = config.x ?? levelX;
// 更新层级节点数量
const currentCount = this.levelCountMap.get(config.level) || 0;
const totalNodesAfterAdd = currentCount + 1;
this.levelCountMap.set(config.level, totalNodesAfterAdd);
// 创建子节点(临时位置,后续重排)
const childNode = this.addNode({
...config,
x: nodeX,
y: 0, // 临时Y值
});
// 创建连线
const edge = graph.addEdge({
shape: "direct-curved-dashed-edge",
source: {
cell: parentId,
anchor: { name: "right" },
},
// 目标节点(子节点)的锚点:左侧
target: {
cell: childNode.id,
anchor: { name: "left" },
},
style: {
stroke: "#888",
strokeWidth: 1.2,
targetMarker: { name: "block", size: 8 },
},
});
if (!graph.isEdge(edge)) {
throw new Error(`创建连线(${parentId}${childNode.id})失败`);
}
// // 重排同层级节点
// this.rearrangeLevelNodes(config.level);
// // 新增:如果添加的是第三层级,同步重排第二层级
// if (config.level === "level3") {
// this.rearrangeLevelNodes("level2");
// }
if (!skipRearrange) {
this.rearrangeLevelNodes(config.level);
// 新增:如果添加的是第三层级,同步重排第二层级
if (config.level === "level3") {
this.rearrangeLevelNodes("level2");
}
// 如果新增level4要重排level3父层级父节点位置可能变化
if (config.level === "level4") {
this.rearrangeLevelNodes("level3");
}
}
return { node: childNode, edge: edge as Edge };
}
/**
* 重排指定层级的节点(垂直分布,第三层级跟随父节点,第二层级间距动态适配子节点数)
*/
private rearrangeLevelNodes(level: NodeLevel): void {
const graph = this.getGraph();
const containerHeight = graph.container.clientHeight;
const centerY = (containerHeight - this.nodeStyle.height) / 2;
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") || "";
return labelA.localeCompare(labelB, undefined, { numeric: true });
});
const totalNodes = levelNodes.length;
if (totalNodes === 0) return;
if (level === "level2") {
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";
}).length;
level2ChildCount.set(node.id, level3ChildCount);
});
const baseGap = 100;
const factor = 30;
let currentY = centerY;
const nodePositions = new Map<string, number>();
const firstNode = levelNodes[0];
nodePositions.set(firstNode.id, currentY);
for (let i = 1; i < totalNodes; i++) {
const prevNode = levelNodes[i - 1];
const prevChildCount = level2ChildCount.get(prevNode.id) || 0;
const dynamicGap = baseGap + prevChildCount * factor;
currentY += this.nodeStyle.height + dynamicGap;
nodePositions.set(levelNodes[i].id, currentY);
}
// ✅修复括号优先级
const firstY = nodePositions.get(levelNodes[0].id) ?? 0;
const lastNodeY = nodePositions.get(levelNodes[totalNodes - 1].id) ?? 0;
const totalHeight = lastNodeY - firstY + this.nodeStyle.height;
const offset = centerY - firstY - totalHeight / 2;
levelNodes.forEach((node) => {
const nodeY = (nodePositions.get(node.id) || 0) + offset;
const nodeX = this.levelXMap.get("level2") || 300;
node.setPosition(nodeX, nodeY);
});
} else if (level === "level3") {
const parentGroups = new Map<string, Node[]>();
levelNodes.forEach((node) => {
const parentEdge = graph
.getEdges()
.find((edge) => edge.getTargetCellId() === node.id);
if (parentEdge) {
const parentId = parentEdge.getSourceCellId();
parentGroups.set(parentId, [
...(parentGroups.get(parentId) || []),
node,
]);
}
});
parentGroups.forEach((children, parentId) => {
const parentNode = graph.getCellById(parentId) as Node;
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 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;
child.setPosition(childX, childY);
});
});
} else if (level === "level4") {
const levelNodes = this.graph.getCells().filter((cell) => {
return cell.isNode() && cell.getData()?.level === level;
}) as Node[];
if (!levelNodes.length) return;
const allEdges = this.graph.getEdges();
//按照父节点Y排序保证顺序和左侧level3从上到下保持一致
levelNodes.sort((a, b) => {
let ay = 0,
by = 0;
for (const e of allEdges) {
if (e.getTargetCellId() === a.id) {
const p = this.graph.getCellById(e.getSourceCellId()) as Node;
ay = p.getPosition().y;
}
if (e.getTargetCellId() === b.id) {
const p = this.graph.getCellById(e.getSourceCellId()) as Node;
by = p.getPosition().y;
}
}
return ay - by;
});
const nodeH = this.nodeStyle.height;
const verticalGap = 20;
const totalHeight =
levelNodes.length * nodeH + (levelNodes.length - 1) * verticalGap;
//参考level3整体的中心点
const level3Nodes = this.graph
.getCells()
.filter((c) => c.isNode() && c.getData()?.level === "level3") as Node[];
let refCenterY = 300;
if (level3Nodes.length > 0) {
const topY = Math.min(...level3Nodes.map((n) => n.getPosition().y));
const botY =
Math.max(...level3Nodes.map((n) => n.getPosition().y)) + nodeH;
refCenterY = (topY + botY) / 2;
}
const startY = refCenterY - totalHeight / 2;
const level4X = this.levelXMap.get("level4") || 800;
let currentY = startY;
levelNodes.forEach((node) => {
node.setPosition(level4X, currentY);
currentY += nodeH + verticalGap;
});
}
}
/**
* 删除节点(递归删除子节点并重新排列)
*/
removeNode(nodeId: string): void {
const graph = this.getGraph();
const cell = graph.getCellById(nodeId);
if (!cell || !graph.isNode(cell)) return;
// if (targetNode.getData().tbslFlag) {
// // 1、拿到被删节点的直接父节点
// const inEdges = graph.getIncomingEdges(graph.getCellById(nodeId));
// inEdges[0].setData({ ...inEdges[0].getData(), syncCount: null });
// inEdges[0].setAttrs({
// label: {
// text: 22222,
// },
// });
// }
if (cell.data.tbslFlag) {
graph.getEdges().forEach((edge) => {
const sourceNode = edge.getSourceNode();
const targetNode = edge.getTargetNode();
if (targetNode.id == cell.id) {
sourceNode.setData({ syncCount: null });
}
});
const inEdges = graph.getIncomingEdges(cell);
inEdges[0].setData({ ...inEdges[0].getData(), syncCount: null });
inEdges[0].setAttrs({
label: {
text: 22222,
},
});
}
const node = cell as Node;
const nodeLevel = node.getData().level as NodeLevel;
// 递归删除子节点
this.getChildNodeIds(nodeId).forEach((childId) => {
this.removeNode(childId);
});
// 删除当前节点
node.remove();
// 重置数量并重新排列
if (nodeLevel) {
this.resetLevelCount(nodeLevel);
this.rearrangeLevelNodes(nodeLevel);
}
}
/**
* 灌库方法将目标数据按层级创建到画布根节点→level2→level3→level4
* @param importData - 目标灌库数据(格式如 [{userName: "...", tables: [...]}]
* @param rootConfig - 根节点配置(需提前确定根节点的 id 和 label如 sourceOwner
*/
importData(importData: ImportDataItem[], rootId): void {
// 3. 遍历灌库数据,创建 level2→level3→level4 层级
importData.forEach((item) => {
// 3.1 创建 level2 节点userName 作为 label父节点是根节点
const { node: level2Node } = this.addChildNode(
rootId,
{
id: uuidv4(),
label: item.userName,
level: "level2",
},
true,
);
// 3.2 遍历当前 level2 的 tables创建 level3 和 level4 节点
item.tables.forEach((table) => {
// 3.2.1 创建 level3 节点tableName 作为 label父节点是当前 level2
const { node: level3Node } = this.addChildNode(
level2Node.id,
{
id: uuidv4(),
label: table.tableName,
level: "level3",
},
true,
);
// 3.2.2 创建 level4 节点columnName 作为 label父节点是当前 level3
this.addChildNode(
level3Node.id,
{
id: uuidv4(),
label: table.columnName,
level: "level4",
},
true,
);
if (table.syncCount) {
level3Node.data.syncCount = table.syncCount;
console.log(level3Node, "3333");
this.addChildNode(level3Node.id, {
id: uuidv4(),
label: table.syncCount.toString(),
level: "level4",
});
}
}, true);
});
// // 全部节点循环创建完成后,统一执行全套重排
this.rearrangeLevelNodes("level2");
this.rearrangeLevelNodes("level3");
this.rearrangeLevelNodes("level4");
this.graph.zoomToFit({
padding: 40, // 四周留白,节点不会贴边框
maxZoom: 1, // 最大缩放比例,不要放太大;数据多时会缩小
minZoom: 0.3,
});
}
/**
* 获取目标表格数据(按 level2→level3→level4 层级组装,含子级完整性校验)
* @param taskId - 传入的任务ID
* @param name - 传入的targetOwner字段值
* @returns 符合格式的目标表格数组 / 错误信息
*/
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");
if (level2Nodes.length === 0) {
return { error: "未找到任何 level2 节点,无法组装数据" };
}
// 2. 遍历每个 level2 节点,校验子级并组装数据
for (const level2Node of level2Nodes) {
const sourceOwner = level2Node.attr("label/text") || "";
if (!sourceOwner) {
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");
if (level3Nodes.length === 0) {
return {
error: `level2 节点「${sourceOwner}」下无 level3 子节点,请完善配置`,
};
}
// 2.2 遍历每个 level3 节点,校验子级并组装数据
for (const level3Node of level3Nodes) {
const tableName = level3Node.attr("label/text") || "";
if (!tableName) {
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");
if (level4Nodes.length === 0) {
return {
error: `level3 节点「${tableName}」下无 level4 子节点,请完善配置`,
};
}
// 2.2.2 遍历每个 level4 节点每个level3仅取第一个有效level4避免重复
const validLevel4Node = level4Nodes[0];
const columnName = validLevel4Node.attr("label/text") || "";
if (!columnName) {
return {
error: `存在未设置 label 的 level4 节点(父级:${tableName}`,
};
}
// 2.2.3 组装单条数据
result.push({
taskId,
sourceOwner,
targetOwner: name, // 传入的name字段作为targetOwner
tableName,
columnName,
syncCount,
});
}
}
// 3. 数据组装完成,返回结果
return result.length > 0 ? result : null;
}
/**
* 重置层级节点数量
*/
private resetLevelCount(level: NodeLevel): void {
const graph = this.getGraph();
const actualCount = graph.getNodes().filter((node) => {
return node.getData().level === level;
}).length;
this.levelCountMap.set(level, actualCount);
}
/**
* 获取子节点ID列表
*/
private getChildNodeIds(parentId: string): string[] {
return this.getGraph()
.getEdges()
.filter((edge) => edge.getSourceCellId() === parentId)
.map((edge) => edge.getTargetCellId());
}
/**
* 适配视图
*/
zoomToFitView(): void {
this.getGraph().zoomToFit({ padding: 50 });
}
/**
* 清空画布
*/
clear(): void {
this.getGraph().clearCells();
this.levelCountMap.set("level2", 0);
this.levelCountMap.set("level3", 0);
this.levelCountMap.set("level4", 0);
}
/**
* 销毁画布
*/
destroy(): void {
this.graph?.destroy();
this.graph = null;
// 移除右键菜单
if (this.menuElement && this.menuElement.parentElement) {
this.menuElement.parentElement.removeChild(this.menuElement);
}
}
/**
* 获取画布实例
*/
private getGraph(): Graph {
if (!this.graph) {
throw new Error("请先调用 init() 初始化画布");
}
return this.graph;
}
}